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
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseArgs, loadConfigFile, extractConfigPath, type CodegenConfig } from './cli.js'
2
+ import { vi } from 'vitest'
3
+ import { parseArgs, loadConfigFile, extractConfigPath, printPostRunHints, warnIfKotlinNoOpFlags, type CodegenConfig } from './cli.js'
3
4
 
4
5
  describe('parseArgs', () => {
5
6
  it('parses --url and --out', () => {
@@ -293,3 +294,201 @@ describe('config file support', () => {
293
294
  await expect(loadConfigFile('/nonexistent/config.json')).rejects.toThrow('Failed to load config')
294
295
  })
295
296
  })
297
+
298
+ describe('cli — kotlin target', () => {
299
+ it('parses --target kotlin and --kotlin-package from CLI flags', () => {
300
+ const args = parseArgs(
301
+ ['--target', 'kotlin', '--kotlin-package', 'com.example.api', '--out', 'out', '--file', 'env.json'],
302
+ )
303
+ expect(args.target).toBe('kotlin')
304
+ expect(args.kotlin?.package).toBe('com.example.api')
305
+ })
306
+
307
+ it('reads kotlin.package from config when no CLI flag is provided', () => {
308
+ const config: CodegenConfig = {
309
+ target: 'kotlin',
310
+ kotlin: { package: 'com.example.api' },
311
+ outDir: 'out',
312
+ file: 'env.json',
313
+ }
314
+ const args = parseArgs(['--out', 'out', '--file', 'env.json'], config)
315
+ expect(args.target).toBe('kotlin')
316
+ expect(args.kotlin?.package).toBe('com.example.api')
317
+ })
318
+
319
+ it('CLI flag overrides config value', () => {
320
+ const config: CodegenConfig = {
321
+ target: 'kotlin',
322
+ kotlin: { package: 'old.pkg' },
323
+ outDir: 'out',
324
+ file: 'env.json',
325
+ }
326
+ const args = parseArgs(['--kotlin-package', 'new.pkg', '--out', 'out', '--file', 'env.json'], config)
327
+ expect(args.kotlin?.package).toBe('new.pkg')
328
+ })
329
+
330
+ it('errors when target is kotlin and no package is provided', () => {
331
+ expect(() =>
332
+ parseArgs(['--target', 'kotlin', '--out', 'out', '--file', 'env.json']),
333
+ ).toThrow(/--kotlin-package/)
334
+ })
335
+
336
+ it('--target ts is the default', () => {
337
+ const args = parseArgs(['--out', 'out', '--file', 'env.json'])
338
+ expect(args.target ?? 'ts').toBe('ts')
339
+ })
340
+ })
341
+
342
+ describe('cli — kotlin-serializer flag', () => {
343
+ it('parses --kotlin-serializer kotlinx', () => {
344
+ const args = parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'kotlinx', '--out', 'o', '--file', 'e.json'])
345
+ expect(args.kotlin?.serializer).toBe('kotlinx')
346
+ })
347
+
348
+ it('parses --kotlin-serializer none', () => {
349
+ const args = parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'none', '--out', 'o', '--file', 'e.json'])
350
+ expect(args.kotlin?.serializer).toBe('none')
351
+ })
352
+
353
+ it('reads kotlin.serializer from config', () => {
354
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'], {
355
+ target: 'kotlin', kotlin: { package: 'p', serializer: 'none' }, outDir: 'o', file: 'e.json',
356
+ } as CodegenConfig)
357
+ expect(args.kotlin?.serializer).toBe('none')
358
+ })
359
+
360
+ it('CLI overrides config', () => {
361
+ const args = parseArgs(['--kotlin-serializer', 'kotlinx', '--out', 'o', '--file', 'e.json'], {
362
+ target: 'kotlin', kotlin: { package: 'p', serializer: 'none' }, outDir: 'o', file: 'e.json',
363
+ } as CodegenConfig)
364
+ expect(args.kotlin?.serializer).toBe('kotlinx')
365
+ })
366
+
367
+ it('throws on invalid value', () => {
368
+ expect(() => parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'bogus', '--out', 'o', '--file', 'e.json']))
369
+ .toThrow(/--kotlin-serializer/)
370
+ })
371
+ })
372
+
373
+ describe('cli — unsupported-unions flag', () => {
374
+ it('parses --unsupported-unions throw', () => {
375
+ const args = parseArgs(['--unsupported-unions', 'throw', '--out', 'o', '--file', 'e.json'])
376
+ expect(args.unsupportedUnions).toBe('throw')
377
+ })
378
+
379
+ it('parses --unsupported-unions fallback', () => {
380
+ const args = parseArgs(['--unsupported-unions', 'fallback', '--out', 'o', '--file', 'e.json'])
381
+ expect(args.unsupportedUnions).toBe('fallback')
382
+ })
383
+
384
+ it('reads unsupportedUnions from config', () => {
385
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'], {
386
+ unsupportedUnions: 'fallback', outDir: 'o', file: 'e.json',
387
+ } as CodegenConfig)
388
+ expect(args.unsupportedUnions).toBe('fallback')
389
+ })
390
+
391
+ it('CLI overrides config', () => {
392
+ const args = parseArgs(['--unsupported-unions', 'throw', '--out', 'o', '--file', 'e.json'], {
393
+ unsupportedUnions: 'fallback', outDir: 'o', file: 'e.json',
394
+ } as CodegenConfig)
395
+ expect(args.unsupportedUnions).toBe('throw')
396
+ })
397
+
398
+ it('throws on invalid value', () => {
399
+ expect(() => parseArgs(['--unsupported-unions', 'bogus', '--out', 'o', '--file', 'e.json']))
400
+ .toThrow(/--unsupported-unions/)
401
+ })
402
+
403
+ it('default is undefined when not set', () => {
404
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'])
405
+ expect(args.unsupportedUnions).toBeUndefined()
406
+ })
407
+ })
408
+
409
+ describe('cli — printPostRunHints', () => {
410
+ it('prints a setup-guide pointer for the kotlin target', () => {
411
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
412
+ try {
413
+ printPostRunHints({ target: 'kotlin' })
414
+ const matched = logSpy.mock.calls.some((c) => String(c[0]).includes('docs/codegen-kotlin.md'))
415
+ expect(matched).toBe(true)
416
+ } finally {
417
+ logSpy.mockRestore()
418
+ }
419
+ })
420
+
421
+ it('prints nothing for the ts target', () => {
422
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
423
+ try {
424
+ printPostRunHints({ target: 'ts' })
425
+ expect(logSpy).not.toHaveBeenCalled()
426
+ } finally {
427
+ logSpy.mockRestore()
428
+ }
429
+ })
430
+
431
+ it('prints nothing when target is undefined (default ts)', () => {
432
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
433
+ try {
434
+ printPostRunHints({})
435
+ expect(logSpy).not.toHaveBeenCalled()
436
+ } finally {
437
+ logSpy.mockRestore()
438
+ }
439
+ })
440
+ })
441
+
442
+ describe('cli — warnIfKotlinNoOpFlags', () => {
443
+ it('warns when --unsupported-unions is set with --target kotlin', () => {
444
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
445
+ try {
446
+ warnIfKotlinNoOpFlags({ target: 'kotlin', unsupportedUnions: 'fallback' })
447
+ const matched = warnSpy.mock.calls.some((c) => String(c[0]).includes('--unsupported-unions is currently a no-op'))
448
+ expect(matched).toBe(true)
449
+ } finally {
450
+ warnSpy.mockRestore()
451
+ }
452
+ })
453
+
454
+ it('warns when --unsupported-unions is set to throw with --target kotlin', () => {
455
+ // Even 'throw' is a no-op since ajsc never throws on Kotlin untagged oneOf.
456
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
457
+ try {
458
+ warnIfKotlinNoOpFlags({ target: 'kotlin', unsupportedUnions: 'throw' })
459
+ expect(warnSpy).toHaveBeenCalled()
460
+ } finally {
461
+ warnSpy.mockRestore()
462
+ }
463
+ })
464
+
465
+ it('does not warn when --unsupported-unions is unset', () => {
466
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
467
+ try {
468
+ warnIfKotlinNoOpFlags({ target: 'kotlin' })
469
+ expect(warnSpy).not.toHaveBeenCalled()
470
+ } finally {
471
+ warnSpy.mockRestore()
472
+ }
473
+ })
474
+
475
+ it('does not warn for the ts target even when --unsupported-unions is set', () => {
476
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
477
+ try {
478
+ warnIfKotlinNoOpFlags({ target: 'ts', unsupportedUnions: 'fallback' })
479
+ expect(warnSpy).not.toHaveBeenCalled()
480
+ } finally {
481
+ warnSpy.mockRestore()
482
+ }
483
+ })
484
+
485
+ it('does not warn when target is undefined', () => {
486
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
487
+ try {
488
+ warnIfKotlinNoOpFlags({ unsupportedUnions: 'fallback' })
489
+ expect(warnSpy).not.toHaveBeenCalled()
490
+ } finally {
491
+ warnSpy.mockRestore()
492
+ }
493
+ })
494
+ })
@@ -22,6 +22,10 @@ export interface CodegenConfig {
22
22
  selfContained?: boolean
23
23
  serviceName?: string
24
24
  cleanOutDir?: boolean
25
+ target?: 'ts' | 'kotlin' | 'swift'
26
+ kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
27
+ swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
28
+ unsupportedUnions?: 'throw' | 'fallback'
25
29
  }
26
30
 
27
31
  export interface ParsedArgs {
@@ -37,6 +41,10 @@ export interface ParsedArgs {
37
41
  selfContained: boolean
38
42
  serviceName?: string
39
43
  cleanOutDir: boolean
44
+ target?: 'ts' | 'kotlin' | 'swift'
45
+ kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
46
+ swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
47
+ unsupportedUnions?: 'throw' | 'fallback'
40
48
  }
41
49
 
42
50
  // ---------------------------------------------------------------------------
@@ -88,6 +96,12 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
88
96
  let selfContained = config?.selfContained ?? true
89
97
  let serviceName: string | undefined = config?.serviceName
90
98
  let cleanOutDir = config?.cleanOutDir ?? false
99
+ let target: 'ts' | 'kotlin' | 'swift' | undefined = config?.target
100
+ let kotlinPackage: string | undefined = config?.kotlin?.package
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
104
+ let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
91
105
  let configPath: string | undefined
92
106
 
93
107
  for (let i = 0; i < argv.length; i++) {
@@ -137,6 +151,43 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
137
151
  cleanOutDir = true
138
152
  } else if (arg === '--no-clean-out-dir') {
139
153
  cleanOutDir = false
154
+ } else if (arg === '--target') {
155
+ const val = argv[++i]
156
+ if (val === 'ts' || val === 'kotlin' || val === 'swift') {
157
+ target = val
158
+ } else {
159
+ throw new Error(`Invalid --target value: ${val ?? '(missing)'} (expected 'ts', 'kotlin', or 'swift')`)
160
+ }
161
+ } else if (arg === '--kotlin-package') {
162
+ kotlinPackage = argv[++i]
163
+ } else if (arg === '--kotlin-serializer') {
164
+ const val = argv[++i]
165
+ if (val === 'kotlinx' || val === 'none') {
166
+ kotlinSerializer = val
167
+ } else {
168
+ throw new Error(`Invalid --kotlin-serializer value: ${val ?? '(missing)'} (expected 'kotlinx' or 'none')`)
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
+ }
184
+ } else if (arg === '--unsupported-unions') {
185
+ const val = argv[++i]
186
+ if (val === 'throw' || val === 'fallback') {
187
+ unsupportedUnions = val
188
+ } else {
189
+ throw new Error(`Invalid --unsupported-unions value: ${val ?? '(missing)'} (expected 'throw' or 'fallback')`)
190
+ }
140
191
  } else if (arg === '--config') {
141
192
  configPath = argv[++i]
142
193
  }
@@ -145,6 +196,14 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
145
196
  // configPath is consumed by the caller (main) before parseArgs is called with the loaded config.
146
197
  // When called from main, config is already loaded. When called directly (tests), configPath is ignored.
147
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
+
148
207
  if (outDir === undefined) {
149
208
  throw new Error('Missing required argument: --out <dir>')
150
209
  }
@@ -153,6 +212,15 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
153
212
  throw new Error('Missing required input source: provide --url <url> or --file <path>')
154
213
  }
155
214
 
215
+ // Kotlin target requires a package; surface this before any I/O happens.
216
+ if (target === 'kotlin' && (kotlinPackage === undefined || kotlinPackage === '')) {
217
+ throw new Error('Missing required argument: --kotlin-package <pkg> (required when --target kotlin)')
218
+ }
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
+
156
224
  return {
157
225
  url,
158
226
  file,
@@ -166,6 +234,24 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
166
234
  selfContained,
167
235
  ...(serviceName !== undefined ? { serviceName } : {}),
168
236
  cleanOutDir,
237
+ ...(target !== undefined ? { target } : {}),
238
+ ...(kotlinPackage !== undefined
239
+ ? {
240
+ kotlin: {
241
+ package: kotlinPackage,
242
+ ...(kotlinSerializer !== undefined ? { serializer: kotlinSerializer } : {}),
243
+ },
244
+ }
245
+ : {}),
246
+ ...(swiftSerializer !== undefined || swiftAccessLevel !== undefined
247
+ ? {
248
+ swift: {
249
+ ...(swiftSerializer !== undefined ? { serializer: swiftSerializer } : {}),
250
+ ...(swiftAccessLevel !== undefined ? { accessLevel: swiftAccessLevel } : {}),
251
+ },
252
+ }
253
+ : {}),
254
+ ...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
169
255
  }
170
256
  }
171
257
 
@@ -201,6 +287,29 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
201
287
  cleanOutDir: parsed.cleanOutDir,
202
288
  }
203
289
 
290
+ // Resolve the kotlin emitter once at watch start; it's stateless and reused per tick.
291
+ const kotlinWiring =
292
+ parsed.target === 'kotlin'
293
+ ? {
294
+ target: 'kotlin' as const,
295
+ kotlinPackage: parsed.kotlin!.package,
296
+ kotlinEmitter: await (
297
+ await import('../targets/kotlin/ajsc-adapter.js')
298
+ ).resolveProductionKotlinEmitter(),
299
+ }
300
+ : {}
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
+
204
313
  let lastHash: string | undefined
205
314
 
206
315
  const run = async (): Promise<void> => {
@@ -226,6 +335,12 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
226
335
  selfContained: parsed.selfContained,
227
336
  serviceName: parsed.serviceName,
228
337
  cleanOutDir: parsed.cleanOutDir,
338
+ ...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
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 } : {}),
342
+ ...kotlinWiring,
343
+ ...swiftWiring,
229
344
  })
230
345
  console.log(`[ts-procedures-codegen] Generated client files → ${parsed.outDir}`)
231
346
  } catch (err) {
@@ -243,6 +358,45 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
243
358
  // Main
244
359
  // ---------------------------------------------------------------------------
245
360
 
361
+ const KOTLIN_SETUP_GUIDE_URL =
362
+ 'https://bitbucket.org/thermsio/ts-procedures/src/master/docs/codegen-kotlin.md'
363
+
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 {
368
+ if (parsed.target === 'kotlin') {
369
+ console.log(`[ts-procedures-codegen] Kotlin setup guide: ${KOTLIN_SETUP_GUIDE_URL}`)
370
+ }
371
+ if (parsed.target === 'swift') {
372
+ console.log(`[ts-procedures-codegen] Swift setup guide: ${SWIFT_SETUP_GUIDE_URL}`)
373
+ }
374
+ }
375
+
376
+ /**
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
+ *
384
+ * Currently: `--unsupported-unions` is a no-op because ajsc v7.2's Kotlin
385
+ * emitter silently emits an empty data class for untagged oneOf regardless
386
+ * of the flag (see docs/codegen-kotlin.md#untagged-unions).
387
+ */
388
+ export function warnIfKotlinNoOpFlags(parsed: {
389
+ target?: 'ts' | 'kotlin'
390
+ unsupportedUnions?: 'throw' | 'fallback'
391
+ }): void {
392
+ if (parsed.target === 'kotlin' && parsed.unsupportedUnions !== undefined) {
393
+ console.warn(
394
+ '[ts-procedures-codegen] Note: --unsupported-unions is currently a no-op for --target kotlin ' +
395
+ '(ajsc v7.2 emits an empty data class regardless). See docs/codegen-kotlin.md#untagged-unions.',
396
+ )
397
+ }
398
+ }
399
+
246
400
  async function main(): Promise<void> {
247
401
  const argv = process.argv.slice(2)
248
402
  const configPath = extractConfigPath(argv)
@@ -251,6 +405,15 @@ async function main(): Promise<void> {
251
405
  console.log(`[ts-procedures-codegen] Loaded config from ${configPath ?? DEFAULT_CONFIG_NAME}`)
252
406
  }
253
407
  const parsed = parseArgs(argv, config)
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
+ }
254
417
 
255
418
  const source = parsed.url ?? parsed.file!
256
419
  console.log(`[ts-procedures-codegen] Reading docs from ${source}...`)
@@ -258,6 +421,23 @@ async function main(): Promise<void> {
258
421
  if (parsed.watch) {
259
422
  await runWithWatch(parsed)
260
423
  } else {
424
+ const kotlinWiring =
425
+ parsed.target === 'kotlin'
426
+ ? {
427
+ target: 'kotlin' as const,
428
+ kotlinPackage: parsed.kotlin!.package,
429
+ kotlinEmitter: await (await import('../targets/kotlin/ajsc-adapter.js')).resolveProductionKotlinEmitter(),
430
+ }
431
+ : {}
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
+
261
441
  const result = await generateClient({
262
442
  url: parsed.url,
263
443
  file: parsed.file,
@@ -269,11 +449,18 @@ async function main(): Promise<void> {
269
449
  selfContained: parsed.selfContained,
270
450
  serviceName: parsed.serviceName,
271
451
  cleanOutDir: parsed.cleanOutDir,
452
+ ...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
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 } : {}),
456
+ ...kotlinWiring,
457
+ ...swiftWiring,
272
458
  })
273
459
  if (parsed.dryRun) {
274
460
  console.log(`[ts-procedures-codegen] Dry run complete — ${result.length} files would be generated`)
275
461
  } else {
276
462
  console.log(`[ts-procedures-codegen] Generated ${result.length} files → ${parsed.outDir}`)
463
+ printPostRunHints(parsed)
277
464
  }
278
465
  }
279
466
  }