ts-procedures 6.1.0 → 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 (163) hide show
  1. package/agent_config/bin/setup.mjs +2 -2
  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 +1 -1
  4. package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +1 -1
  5. package/agent_config/claude-code/skills/ts-procedures-swift/SKILL.md +119 -0
  6. package/agent_config/copilot/copilot-instructions.md +1 -0
  7. package/agent_config/cursor/cursorrules +1 -0
  8. package/agent_config/lib/install-claude.mjs +1 -1
  9. package/build/codegen/bin/cli.d.ts +17 -3
  10. package/build/codegen/bin/cli.js +79 -3
  11. package/build/codegen/bin/cli.js.map +1 -1
  12. package/build/codegen/index.d.ts +18 -1
  13. package/build/codegen/index.js +3 -0
  14. package/build/codegen/index.js.map +1 -1
  15. package/build/codegen/pipeline.d.ts +16 -5
  16. package/build/codegen/pipeline.js +44 -143
  17. package/build/codegen/pipeline.js.map +1 -1
  18. package/build/codegen/targets/_shared/error-schemas.d.ts +10 -0
  19. package/build/codegen/targets/_shared/error-schemas.js +17 -0
  20. package/build/codegen/targets/_shared/error-schemas.js.map +1 -0
  21. package/build/codegen/targets/_shared/error-schemas.test.d.ts +1 -0
  22. package/build/codegen/targets/_shared/error-schemas.test.js +38 -0
  23. package/build/codegen/targets/_shared/error-schemas.test.js.map +1 -0
  24. package/build/codegen/targets/_shared/indent.d.ts +6 -0
  25. package/build/codegen/targets/_shared/indent.js +13 -0
  26. package/build/codegen/targets/_shared/indent.js.map +1 -0
  27. package/build/codegen/targets/_shared/indent.test.d.ts +1 -0
  28. package/build/codegen/targets/_shared/indent.test.js +21 -0
  29. package/build/codegen/targets/_shared/indent.test.js.map +1 -0
  30. package/build/codegen/targets/_shared/pascal-case.d.ts +6 -0
  31. package/build/codegen/targets/_shared/pascal-case.js +13 -0
  32. package/build/codegen/targets/_shared/pascal-case.js.map +1 -0
  33. package/build/codegen/targets/_shared/pascal-case.test.d.ts +1 -0
  34. package/build/codegen/targets/_shared/pascal-case.test.js +25 -0
  35. package/build/codegen/targets/_shared/pascal-case.test.js.map +1 -0
  36. package/build/codegen/targets/_shared/path-utils.d.ts +12 -0
  37. package/build/codegen/targets/_shared/path-utils.js +20 -0
  38. package/build/codegen/targets/_shared/path-utils.js.map +1 -0
  39. package/build/codegen/targets/_shared/path-utils.test.d.ts +1 -0
  40. package/build/codegen/targets/_shared/path-utils.test.js +42 -0
  41. package/build/codegen/targets/_shared/path-utils.test.js.map +1 -0
  42. package/build/codegen/targets/_shared/pick-defined.d.ts +11 -0
  43. package/build/codegen/targets/_shared/pick-defined.js +21 -0
  44. package/build/codegen/targets/_shared/pick-defined.js.map +1 -0
  45. package/build/codegen/targets/_shared/pick-defined.test.d.ts +1 -0
  46. package/build/codegen/targets/_shared/pick-defined.test.js +25 -0
  47. package/build/codegen/targets/_shared/pick-defined.test.js.map +1 -0
  48. package/build/codegen/targets/_shared/route-slots.d.ts +17 -0
  49. package/build/codegen/targets/_shared/route-slots.js +17 -0
  50. package/build/codegen/targets/_shared/route-slots.js.map +1 -0
  51. package/build/codegen/targets/_shared/route-slots.test.d.ts +1 -0
  52. package/build/codegen/targets/_shared/route-slots.test.js +43 -0
  53. package/build/codegen/targets/_shared/route-slots.test.js.map +1 -0
  54. package/build/codegen/targets/_shared/target-run.d.ts +27 -0
  55. package/build/codegen/targets/_shared/target-run.js +2 -0
  56. package/build/codegen/targets/_shared/target-run.js.map +1 -0
  57. package/build/codegen/targets/_shared/write-files.d.ts +24 -0
  58. package/build/codegen/targets/_shared/write-files.js +35 -0
  59. package/build/codegen/targets/_shared/write-files.js.map +1 -0
  60. package/build/codegen/targets/_shared/write-files.test.d.ts +1 -0
  61. package/build/codegen/targets/_shared/write-files.test.js +79 -0
  62. package/build/codegen/targets/_shared/write-files.test.js.map +1 -0
  63. package/build/codegen/targets/kotlin/e2e-compile.test.js +1 -1
  64. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
  65. package/build/codegen/targets/kotlin/emit-route-kotlin.js +5 -22
  66. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
  67. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +4 -8
  68. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
  69. package/build/codegen/targets/kotlin/format-kotlin.d.ts +0 -12
  70. package/build/codegen/targets/kotlin/format-kotlin.js +0 -27
  71. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
  72. package/build/codegen/targets/kotlin/format-kotlin.test.js +1 -34
  73. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
  74. package/build/codegen/targets/kotlin/integration.test.js +1 -1
  75. package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
  76. package/build/codegen/targets/kotlin/run.d.ts +11 -0
  77. package/build/codegen/targets/kotlin/run.js +51 -0
  78. package/build/codegen/targets/kotlin/run.js.map +1 -0
  79. package/build/codegen/targets/swift/access-level.test.d.ts +1 -0
  80. package/build/codegen/targets/swift/access-level.test.js +98 -0
  81. package/build/codegen/targets/swift/access-level.test.js.map +1 -0
  82. package/build/codegen/targets/swift/ajsc-adapter.d.ts +27 -0
  83. package/build/codegen/targets/swift/ajsc-adapter.js +38 -0
  84. package/build/codegen/targets/swift/ajsc-adapter.js.map +1 -0
  85. package/build/codegen/targets/swift/ajsc-adapter.test.d.ts +1 -0
  86. package/build/codegen/targets/swift/ajsc-adapter.test.js +37 -0
  87. package/build/codegen/targets/swift/ajsc-adapter.test.js.map +1 -0
  88. package/build/codegen/targets/swift/e2e-compile.test.d.ts +1 -0
  89. package/build/codegen/targets/swift/e2e-compile.test.js +57 -0
  90. package/build/codegen/targets/swift/e2e-compile.test.js.map +1 -0
  91. package/build/codegen/targets/swift/emit-route-swift.d.ts +15 -0
  92. package/build/codegen/targets/swift/emit-route-swift.js +64 -0
  93. package/build/codegen/targets/swift/emit-route-swift.js.map +1 -0
  94. package/build/codegen/targets/swift/emit-route-swift.test.d.ts +1 -0
  95. package/build/codegen/targets/swift/emit-route-swift.test.js +258 -0
  96. package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -0
  97. package/build/codegen/targets/swift/emit-scope-swift.d.ts +13 -0
  98. package/build/codegen/targets/swift/emit-scope-swift.js +36 -0
  99. package/build/codegen/targets/swift/emit-scope-swift.js.map +1 -0
  100. package/build/codegen/targets/swift/emit-scope-swift.test.d.ts +1 -0
  101. package/build/codegen/targets/swift/emit-scope-swift.test.js +136 -0
  102. package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -0
  103. package/build/codegen/targets/swift/format-swift.d.ts +2 -0
  104. package/build/codegen/targets/swift/format-swift.js +10 -0
  105. package/build/codegen/targets/swift/format-swift.js.map +1 -0
  106. package/build/codegen/targets/swift/format-swift.test.d.ts +1 -0
  107. package/build/codegen/targets/swift/format-swift.test.js +14 -0
  108. package/build/codegen/targets/swift/format-swift.test.js.map +1 -0
  109. package/build/codegen/targets/swift/integration.test.d.ts +1 -0
  110. package/build/codegen/targets/swift/integration.test.js +53 -0
  111. package/build/codegen/targets/swift/integration.test.js.map +1 -0
  112. package/build/codegen/targets/swift/run.d.ts +11 -0
  113. package/build/codegen/targets/swift/run.js +47 -0
  114. package/build/codegen/targets/swift/run.js.map +1 -0
  115. package/build/codegen/targets/ts/run.d.ts +4 -0
  116. package/build/codegen/targets/ts/run.js +86 -0
  117. package/build/codegen/targets/ts/run.js.map +1 -0
  118. package/docs/codegen-kotlin.md +1 -0
  119. package/docs/codegen-swift.md +314 -0
  120. package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +1 -1
  121. package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +1 -1
  122. package/docs/superpowers/specs/2026-04-25-swift-codegen-design.md +264 -0
  123. package/package.json +2 -2
  124. package/src/codegen/bin/cli.ts +91 -7
  125. package/src/codegen/index.ts +24 -1
  126. package/src/codegen/pipeline.ts +52 -174
  127. package/src/codegen/targets/_shared/error-schemas.test.ts +42 -0
  128. package/src/codegen/targets/_shared/error-schemas.ts +17 -0
  129. package/src/codegen/targets/_shared/indent.test.ts +25 -0
  130. package/src/codegen/targets/_shared/indent.ts +12 -0
  131. package/src/codegen/targets/_shared/pascal-case.test.ts +30 -0
  132. package/src/codegen/targets/_shared/pascal-case.ts +12 -0
  133. package/src/codegen/targets/_shared/path-utils.test.ts +51 -0
  134. package/src/codegen/targets/_shared/path-utils.ts +21 -0
  135. package/src/codegen/targets/_shared/pick-defined.test.ts +48 -0
  136. package/src/codegen/targets/_shared/pick-defined.ts +23 -0
  137. package/src/codegen/targets/_shared/route-slots.test.ts +55 -0
  138. package/src/codegen/targets/_shared/route-slots.ts +32 -0
  139. package/src/codegen/targets/_shared/target-run.ts +28 -0
  140. package/src/codegen/targets/_shared/write-files.test.ts +110 -0
  141. package/src/codegen/targets/_shared/write-files.ts +53 -0
  142. package/src/codegen/targets/kotlin/e2e-compile.test.ts +1 -1
  143. package/src/codegen/targets/kotlin/emit-route-kotlin.ts +5 -25
  144. package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +4 -9
  145. package/src/codegen/targets/kotlin/format-kotlin.test.ts +0 -44
  146. package/src/codegen/targets/kotlin/format-kotlin.ts +0 -32
  147. package/src/codegen/targets/kotlin/integration.test.ts +1 -1
  148. package/src/codegen/targets/kotlin/run.ts +78 -0
  149. package/src/codegen/targets/swift/__fixtures__/users-golden.swift +123 -0
  150. package/src/codegen/targets/swift/access-level.test.ts +108 -0
  151. package/src/codegen/targets/swift/ajsc-adapter.test.ts +47 -0
  152. package/src/codegen/targets/swift/ajsc-adapter.ts +67 -0
  153. package/src/codegen/targets/swift/e2e-compile.test.ts +66 -0
  154. package/src/codegen/targets/swift/emit-route-swift.test.ts +300 -0
  155. package/src/codegen/targets/swift/emit-route-swift.ts +90 -0
  156. package/src/codegen/targets/swift/emit-scope-swift.test.ts +164 -0
  157. package/src/codegen/targets/swift/emit-scope-swift.ts +59 -0
  158. package/src/codegen/targets/swift/format-swift.test.ts +23 -0
  159. package/src/codegen/targets/swift/format-swift.ts +9 -0
  160. package/src/codegen/targets/swift/integration.test.ts +80 -0
  161. package/src/codegen/targets/swift/run.ts +74 -0
  162. package/src/codegen/targets/ts/run.ts +117 -0
  163. /package/src/codegen/{targets/kotlin/__fixtures__ → __fixtures__}/users-envelope.json +0 -0
@@ -22,8 +22,9 @@ export interface CodegenConfig {
22
22
  selfContained?: boolean
23
23
  serviceName?: string
24
24
  cleanOutDir?: boolean
25
- target?: 'ts' | 'kotlin'
25
+ target?: 'ts' | 'kotlin' | 'swift'
26
26
  kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
27
+ swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
27
28
  unsupportedUnions?: 'throw' | 'fallback'
28
29
  }
29
30
 
@@ -40,8 +41,9 @@ export interface ParsedArgs {
40
41
  selfContained: boolean
41
42
  serviceName?: string
42
43
  cleanOutDir: boolean
43
- target?: 'ts' | 'kotlin'
44
+ target?: 'ts' | 'kotlin' | 'swift'
44
45
  kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
46
+ swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
45
47
  unsupportedUnions?: 'throw' | 'fallback'
46
48
  }
47
49
 
@@ -94,9 +96,11 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
94
96
  let selfContained = config?.selfContained ?? true
95
97
  let serviceName: string | undefined = config?.serviceName
96
98
  let cleanOutDir = config?.cleanOutDir ?? false
97
- let target: 'ts' | 'kotlin' | undefined = config?.target
99
+ let target: 'ts' | 'kotlin' | 'swift' | undefined = config?.target
98
100
  let kotlinPackage: string | undefined = config?.kotlin?.package
99
101
  let kotlinSerializer: 'kotlinx' | 'none' | undefined = config?.kotlin?.serializer
102
+ let swiftSerializer: 'codable' | 'none' | undefined = config?.swift?.serializer
103
+ let swiftAccessLevel: 'public' | 'internal' | undefined = config?.swift?.accessLevel
100
104
  let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
101
105
  let configPath: string | undefined
102
106
 
@@ -149,10 +153,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
149
153
  cleanOutDir = false
150
154
  } else if (arg === '--target') {
151
155
  const val = argv[++i]
152
- if (val === 'ts' || val === 'kotlin') {
156
+ if (val === 'ts' || val === 'kotlin' || val === 'swift') {
153
157
  target = val
154
158
  } else {
155
- throw new Error(`Invalid --target value: ${val ?? '(missing)'} (expected 'ts' or 'kotlin')`)
159
+ throw new Error(`Invalid --target value: ${val ?? '(missing)'} (expected 'ts', 'kotlin', or 'swift')`)
156
160
  }
157
161
  } else if (arg === '--kotlin-package') {
158
162
  kotlinPackage = argv[++i]
@@ -163,6 +167,20 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
163
167
  } else {
164
168
  throw new Error(`Invalid --kotlin-serializer value: ${val ?? '(missing)'} (expected 'kotlinx' or 'none')`)
165
169
  }
170
+ } else if (arg === '--swift-serializer') {
171
+ const val = argv[++i]
172
+ if (val === 'codable' || val === 'none') {
173
+ swiftSerializer = val
174
+ } else {
175
+ throw new Error(`Invalid --swift-serializer value: ${val ?? '(missing)'} (expected 'codable' or 'none')`)
176
+ }
177
+ } else if (arg === '--swift-access-level') {
178
+ const val = argv[++i]
179
+ if (val === 'public' || val === 'internal') {
180
+ swiftAccessLevel = val
181
+ } else {
182
+ throw new Error(`Invalid --swift-access-level value: ${val ?? '(missing)'} (expected 'public' or 'internal')`)
183
+ }
166
184
  } else if (arg === '--unsupported-unions') {
167
185
  const val = argv[++i]
168
186
  if (val === 'throw' || val === 'fallback') {
@@ -178,6 +196,14 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
178
196
  // configPath is consumed by the caller (main) before parseArgs is called with the loaded config.
179
197
  // When called from main, config is already loaded. When called directly (tests), configPath is ignored.
180
198
 
199
+ // ---------------------------------------------------------------------------
200
+ // Validation — fails fast on user-controllable errors before envelope resolve.
201
+ // Runtime checks (emitter availability, etc.) happen later in pipeline.ts;
202
+ // those guards are aimed at non-CLI callers (direct API consumers, tests).
203
+ // The CLI resolves emitters before invoking `runPipeline`, so users only ever
204
+ // see flag-shape errors from this block, not pipeline-internal throws.
205
+ // ---------------------------------------------------------------------------
206
+
181
207
  if (outDir === undefined) {
182
208
  throw new Error('Missing required argument: --out <dir>')
183
209
  }
@@ -186,10 +212,15 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
186
212
  throw new Error('Missing required input source: provide --url <url> or --file <path>')
187
213
  }
188
214
 
215
+ // Kotlin target requires a package; surface this before any I/O happens.
189
216
  if (target === 'kotlin' && (kotlinPackage === undefined || kotlinPackage === '')) {
190
217
  throw new Error('Missing required argument: --kotlin-package <pkg> (required when --target kotlin)')
191
218
  }
192
219
 
220
+ // Swift target currently has no required flags. If that changes, add the
221
+ // guard here so the failure mode stays consistent (flag-shape errors fire
222
+ // from parseArgs; runtime/emitter wiring errors fire from pipeline.ts).
223
+
193
224
  return {
194
225
  url,
195
226
  file,
@@ -212,6 +243,14 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
212
243
  },
213
244
  }
214
245
  : {}),
246
+ ...(swiftSerializer !== undefined || swiftAccessLevel !== undefined
247
+ ? {
248
+ swift: {
249
+ ...(swiftSerializer !== undefined ? { serializer: swiftSerializer } : {}),
250
+ ...(swiftAccessLevel !== undefined ? { accessLevel: swiftAccessLevel } : {}),
251
+ },
252
+ }
253
+ : {}),
215
254
  ...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
216
255
  }
217
256
  }
@@ -260,6 +299,17 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
260
299
  }
261
300
  : {}
262
301
 
302
+ // Resolve the swift emitter once at watch start; it's stateless and reused per tick.
303
+ const swiftWiring =
304
+ parsed.target === 'swift'
305
+ ? {
306
+ target: 'swift' as const,
307
+ swiftEmitter: await (
308
+ await import('../targets/swift/ajsc-adapter.js')
309
+ ).resolveProductionSwiftEmitter(),
310
+ }
311
+ : {}
312
+
263
313
  let lastHash: string | undefined
264
314
 
265
315
  const run = async (): Promise<void> => {
@@ -287,7 +337,10 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
287
337
  cleanOutDir: parsed.cleanOutDir,
288
338
  ...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
289
339
  ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
340
+ ...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
341
+ ...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
290
342
  ...kotlinWiring,
343
+ ...swiftWiring,
291
344
  })
292
345
  console.log(`[ts-procedures-codegen] Generated client files → ${parsed.outDir}`)
293
346
  } catch (err) {
@@ -308,14 +361,26 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
308
361
  const KOTLIN_SETUP_GUIDE_URL =
309
362
  'https://bitbucket.org/thermsio/ts-procedures/src/master/docs/codegen-kotlin.md'
310
363
 
311
- export function printPostRunHints(parsed: { target?: 'ts' | 'kotlin' }): void {
364
+ const SWIFT_SETUP_GUIDE_URL =
365
+ 'https://bitbucket.org/thermsio/ts-procedures/src/master/docs/codegen-swift.md'
366
+
367
+ export function printPostRunHints(parsed: { target?: 'ts' | 'kotlin' | 'swift' }): void {
312
368
  if (parsed.target === 'kotlin') {
313
369
  console.log(`[ts-procedures-codegen] Kotlin setup guide: ${KOTLIN_SETUP_GUIDE_URL}`)
314
370
  }
371
+ if (parsed.target === 'swift') {
372
+ console.log(`[ts-procedures-codegen] Swift setup guide: ${SWIFT_SETUP_GUIDE_URL}`)
373
+ }
315
374
  }
316
375
 
317
376
  /**
318
377
  * Warns about flags that are currently no-ops for the Kotlin target.
378
+ *
379
+ * Scope is intentionally Kotlin-only — the param type omits `'swift'` so a
380
+ * reader can tell at a glance this function will never act on swift. Other
381
+ * targets get their own warner if/when they grow no-op flags. The call site
382
+ * narrows `parsed.target` before invoking.
383
+ *
319
384
  * Currently: `--unsupported-unions` is a no-op because ajsc v7.2's Kotlin
320
385
  * emitter silently emits an empty data class for untagged oneOf regardless
321
386
  * of the flag (see docs/codegen-kotlin.md#untagged-unions).
@@ -340,7 +405,15 @@ async function main(): Promise<void> {
340
405
  console.log(`[ts-procedures-codegen] Loaded config from ${configPath ?? DEFAULT_CONFIG_NAME}`)
341
406
  }
342
407
  const parsed = parseArgs(argv, config)
343
- warnIfKotlinNoOpFlags(parsed)
408
+ // The warner is intentionally Kotlin-only; pass the relevant fields and
409
+ // narrow `target` away from 'swift' here so the function's param type can
410
+ // stay tight.
411
+ if (parsed.target !== 'swift') {
412
+ warnIfKotlinNoOpFlags({
413
+ target: parsed.target,
414
+ ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
415
+ })
416
+ }
344
417
 
345
418
  const source = parsed.url ?? parsed.file!
346
419
  console.log(`[ts-procedures-codegen] Reading docs from ${source}...`)
@@ -357,6 +430,14 @@ async function main(): Promise<void> {
357
430
  }
358
431
  : {}
359
432
 
433
+ const swiftWiring =
434
+ parsed.target === 'swift'
435
+ ? {
436
+ target: 'swift' as const,
437
+ swiftEmitter: await (await import('../targets/swift/ajsc-adapter.js')).resolveProductionSwiftEmitter(),
438
+ }
439
+ : {}
440
+
360
441
  const result = await generateClient({
361
442
  url: parsed.url,
362
443
  file: parsed.file,
@@ -370,7 +451,10 @@ async function main(): Promise<void> {
370
451
  cleanOutDir: parsed.cleanOutDir,
371
452
  ...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
372
453
  ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
454
+ ...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
455
+ ...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
373
456
  ...kotlinWiring,
457
+ ...swiftWiring,
374
458
  })
375
459
  if (parsed.dryRun) {
376
460
  console.log(`[ts-procedures-codegen] Dry run complete — ${result.length} files would be generated`)
@@ -2,6 +2,7 @@ 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
4
  import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
5
+ import type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
5
6
 
6
7
  export interface GenerateClientOptions extends ResolveInput {
7
8
  outDir: string
@@ -12,12 +13,16 @@ export interface GenerateClientOptions extends ResolveInput {
12
13
  selfContained?: boolean
13
14
  serviceName?: string
14
15
  cleanOutDir?: boolean
15
- target?: 'ts' | 'kotlin'
16
+ target?: 'ts' | 'kotlin' | 'swift'
16
17
  kotlinPackage?: string
17
18
  kotlinSerializer?: 'kotlinx' | 'none'
18
19
  unsupportedUnions?: 'throw' | 'fallback'
19
20
  /** Injected for tests; production wiring resolves a real ajsc emitter. */
20
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
21
26
  }
22
27
 
23
28
  export async function generateClient(options: GenerateClientOptions): Promise<GeneratedFile[]> {
@@ -37,6 +42,9 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
37
42
  kotlinSerializer: options.kotlinSerializer,
38
43
  unsupportedUnions: options.unsupportedUnions,
39
44
  kotlinEmitter: options.kotlinEmitter,
45
+ swiftSerializer: options.swiftSerializer,
46
+ swiftAccessLevel: options.swiftAccessLevel,
47
+ swiftEmitter: options.swiftEmitter,
40
48
  })
41
49
  }
42
50
 
@@ -57,3 +65,18 @@ export type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
57
65
 
58
66
  /** Result shape produced by `KotlinEmitter.emit`; stable, useful for stub builders in tests. */
59
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,18 +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'
13
7
  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'
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'
16
12
 
17
13
  export interface PipelineOptions {
18
14
  envelope: DocEnvelope
@@ -24,193 +20,75 @@ export interface PipelineOptions {
24
20
  selfContained?: boolean
25
21
  serviceName?: string
26
22
  cleanOutDir?: boolean
27
- target?: 'ts' | 'kotlin'
23
+ target?: 'ts' | 'kotlin' | 'swift'
28
24
  kotlinPackage?: string
29
25
  kotlinSerializer?: 'kotlinx' | 'none'
30
26
  unsupportedUnions?: 'throw' | 'fallback'
31
27
  /** Injected for tests; production wiring resolves a real ajsc emitter. */
32
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
33
33
  }
34
34
 
35
- export interface GeneratedFile {
36
- path: string
37
- code: string
38
- }
35
+ export type { GeneratedFile } from './targets/_shared/write-files.js'
39
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
+ */
40
45
  export async function runPipeline(options: PipelineOptions): Promise<GeneratedFile[]> {
41
46
  const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = false } = options
42
47
  const serviceName = options.serviceName ?? 'Api'
43
48
  validateServiceName(serviceName)
49
+
44
50
  const clientImportPath = selfContained ? './_types' : options.clientImportPath
45
51
  if (selfContained && options.clientImportPath != null) {
46
52
  console.warn('[ts-procedures-codegen] --self-contained overrides --client-import-path; using ./_types')
47
53
  }
48
54
 
49
55
  const hash = createHash('md5').update(JSON.stringify(envelope)).digest('hex')
50
- const hashComment = `// Source hash: ${hash}`
51
-
52
- const groups = groupRoutesByScope(envelope.routes)
53
- const groupArray = Array.from(groups.values())
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
- }
56
+ const groups = Array.from(groupRoutesByScope(envelope.routes).values())
124
57
 
125
- // Error keys that will be emitted in `_errors.ts` — only those with a schema.
126
- // Scope emit uses this to filter `route.errors` so generated code never
127
- // references an undefined error type.
128
- const errorKeys = new Set(
129
- envelope.errors.filter((e) => e.schema != null).map((e) => e.name)
130
- )
131
-
132
- if (selfContained) {
133
- for (const group of groupArray) {
134
- if (group.scopeKey === '_types' || group.scopeKey === '_client') {
135
- throw new Error(
136
- `[ts-procedures-codegen] Scope "${group.scopeKey}" conflicts with self-contained mode reserved filename "${group.scopeKey}.ts". Rename the scope to avoid collision.`
137
- )
138
- }
139
- }
140
- }
141
-
142
- const files: GeneratedFile[] = []
143
-
144
- for (const group of groupArray) {
145
- const rawCode = await emitScopeFile(group, {
146
- ajsc: ajscOpts,
147
- clientImportPath,
148
- namespaceTypes,
149
- serviceName,
150
- errorKeys: errorKeys.size > 0 ? errorKeys : undefined,
151
- })
152
- const lines = rawCode.split('\n')
153
- lines.splice(1, 0, hashComment)
154
- const code = lines.join('\n')
155
- files.push({ path: join(outDir, `${group.scopeKey}.ts`), code })
156
- }
157
-
158
- const errorsCode = await emitErrorsFile(envelope.errors, { ajsc: ajscOpts, clientImportPath, namespaceTypes, serviceName })
159
- const hasErrors = errorsCode != null
160
- if (errorsCode != null) {
161
- const errorsLines = errorsCode.split('\n')
162
- errorsLines.splice(1, 0, hashComment)
163
- const errorsWithHash = errorsLines.join('\n')
164
- files.push({ path: join(outDir, '_errors.ts'), code: errorsWithHash })
165
- }
166
-
167
- // In self-contained mode types come from `./_types` but the runtime
168
- // (`createClient`) lives in `./_client`. In regular mode both share the
169
- // single `clientImportPath` (e.g. `ts-procedures/client`).
170
- const clientRuntimeImportPath = selfContained ? './_client' : clientImportPath
171
- const rawIndexCode = emitIndexFile(groupArray, {
58
+ const base = {
59
+ envelope,
60
+ outDir,
61
+ hash,
62
+ groups,
63
+ serviceName,
64
+ ajsc: ajscOpts,
172
65
  clientImportPath,
173
- clientRuntimeImportPath,
174
- hasErrors,
66
+ dryRun,
175
67
  namespaceTypes,
176
- serviceName,
177
- })
178
- const indexLines = rawIndexCode.split('\n')
179
- indexLines.splice(1, 0, hashComment)
180
- const indexCode = indexLines.join('\n')
181
- files.push({ path: join(outDir, 'index.ts'), code: indexCode })
182
-
183
- if (selfContained) {
184
- const rawTypesCode = await emitClientTypesFile()
185
- const typesLines = rawTypesCode.split('\n')
186
- typesLines.splice(1, 0, hashComment)
187
- const typesCode = typesLines.join('\n')
188
- files.push({ path: join(outDir, '_types.ts'), code: typesCode })
189
-
190
- const rawClientCode = await emitClientRuntimeFile()
191
- const clientLines = rawClientCode.split('\n')
192
- clientLines.splice(1, 0, hashComment)
193
- const clientCode = clientLines.join('\n')
194
- files.push({ path: join(outDir, '_client.ts'), code: clientCode })
68
+ selfContained,
69
+ cleanOutDir,
195
70
  }
196
71
 
197
- if (dryRun) {
198
- if (cleanOutDir) {
199
- console.log(`[dry-run] Would clean outDir: ${outDir}`)
200
- }
201
- for (const file of files) {
202
- const bytes = Buffer.byteLength(file.code, 'utf-8')
203
- console.log(`[dry-run] Would write: ${file.path} (${bytes} bytes)`)
204
- }
205
- } else {
206
- if (cleanOutDir) {
207
- await rm(outDir, { recursive: true, force: true })
208
- }
209
- await mkdir(outDir, { recursive: true })
210
- for (const file of files) {
211
- await writeFile(file.path, file.code, 'utf-8')
212
- }
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)
213
93
  }
214
-
215
- return files
216
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
+ }