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,300 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
3
+ import { emitSwiftRoute } from './emit-route-swift.js'
4
+ import { createStubSwiftEmitter, type SwiftEmitOptions, type SwiftEmitResult } from './ajsc-adapter.js'
5
+
6
+ const ok = (code: string, rootTypeName: string): SwiftEmitResult => ({
7
+ code,
8
+ rootTypeName,
9
+ extractedTypeNames: [],
10
+ imports: ['Foundation'],
11
+ })
12
+
13
+ const noErrors = new Map<string, unknown>()
14
+
15
+ interface CapturedCall { schema: unknown; opts: SwiftEmitOptions }
16
+
17
+ function makeSpyEmitter(results: Record<string, SwiftEmitResult>) {
18
+ const calls: CapturedCall[] = []
19
+ const emitter = {
20
+ emit(schema: unknown, opts: SwiftEmitOptions) {
21
+ calls.push({ schema, opts })
22
+ const r = results[opts.rootTypeName]
23
+ if (r == null) throw new Error(`No stubbed result for "${opts.rootTypeName}"`)
24
+ return r
25
+ },
26
+ }
27
+ return { emitter, calls }
28
+ }
29
+
30
+ describe('emitSwiftRoute', () => {
31
+ it('emits an api-kind route with path params and a response', () => {
32
+ const route: AnyHttpRouteDoc = {
33
+ kind: 'api',
34
+ name: 'GetUser',
35
+ method: 'GET',
36
+ fullPath: '/users/:id',
37
+ schema: {
38
+ input: {
39
+ pathParams: { type: 'object' },
40
+ },
41
+ returnType: { type: 'object' },
42
+ },
43
+ errors: [],
44
+ } as unknown as AnyHttpRouteDoc
45
+
46
+ const emitter = createStubSwiftEmitter({
47
+ PathParams: ok('public struct PathParams: Codable { public let id: String }', 'PathParams'),
48
+ Response: ok('public struct Response: Codable { public let id: String; public let name: String }', 'Response'),
49
+ })
50
+
51
+ const result = emitSwiftRoute(route, emitter, noErrors)
52
+
53
+ expect(result.imports).toContain('Foundation')
54
+ expect(result.code).toContain('public static let method = "GET"')
55
+ expect(result.code).toContain('public static let pathTemplate = "/users/{id}"')
56
+ expect(result.code).toContain('public static func path(_ p: PathParams) -> String { return "/users/\\(p.id)" }')
57
+ expect(result.code).toContain('public struct PathParams: Codable { public let id: String }')
58
+ expect(result.code).toContain('public struct Response: Codable { public let id: String; public let name: String }')
59
+ })
60
+
61
+ it('emits a route with no path params using a path constant', () => {
62
+ const route = {
63
+ kind: 'api',
64
+ name: 'CreateUser',
65
+ method: 'POST',
66
+ fullPath: '/users',
67
+ schema: { input: { body: { type: 'object' } }, returnType: { type: 'object' } },
68
+ errors: [],
69
+ } as unknown as AnyHttpRouteDoc
70
+
71
+ const emitter = createStubSwiftEmitter({
72
+ Body: ok('public struct Body: Codable { public let name: String }', 'Body'),
73
+ Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
74
+ })
75
+
76
+ const result = emitSwiftRoute(route, emitter, noErrors)
77
+ expect(result.code).toContain('public static let path = "/users"')
78
+ expect(result.code).not.toContain('static func path(')
79
+ })
80
+
81
+ it('emits an Errors namespace for routes whose error keys have schemas in the envelope', () => {
82
+ const route = {
83
+ kind: 'api',
84
+ name: 'GetUser',
85
+ method: 'GET',
86
+ fullPath: '/users/:id',
87
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
88
+ errors: ['NotFound'],
89
+ } as unknown as AnyHttpRouteDoc
90
+
91
+ const emitter = createStubSwiftEmitter({
92
+ PathParams: ok('public struct PathParams: Codable { public let id: String }', 'PathParams'),
93
+ Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
94
+ NotFound: ok('public struct NotFound: Codable { public let name: String; public let message: String }', 'NotFound'),
95
+ })
96
+
97
+ const errorSchemas = new Map<string, unknown>([['NotFound', { type: 'object' }]])
98
+ const result = emitSwiftRoute(route, emitter, errorSchemas)
99
+ expect(result.code).toContain('public enum Errors {')
100
+ expect(result.code).toContain('public struct NotFound: Codable { public let name: String; public let message: String }')
101
+ })
102
+
103
+ it('silently skips error keys with no schema in the envelope map', () => {
104
+ const route = {
105
+ kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users',
106
+ schema: {}, errors: ['UnknownTaxonomyKey'],
107
+ } as unknown as AnyHttpRouteDoc
108
+ const result = emitSwiftRoute(route, createStubSwiftEmitter({}), new Map())
109
+ expect(result.code).not.toContain('enum Errors {')
110
+ })
111
+
112
+ it('returns skipped:true for stream routes', () => {
113
+ const route = { kind: 'stream', name: 'WatchUsers', method: 'GET', path: '/users/stream', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
114
+ const result = emitSwiftRoute(route, createStubSwiftEmitter({}), noErrors)
115
+ expect(result.code).toBe('')
116
+ expect(result.skipped).toBe(true)
117
+ })
118
+
119
+ it('passes inlineTypes:true plus serializer/accessLevel/unsupportedUnions to every slot emit', () => {
120
+ const route = {
121
+ kind: 'api',
122
+ name: 'GetUser',
123
+ method: 'GET',
124
+ fullPath: '/users/:id',
125
+ schema: {
126
+ input: { pathParams: { type: 'object' } },
127
+ returnType: { type: 'object' },
128
+ },
129
+ errors: ['NotFound'],
130
+ } as unknown as AnyHttpRouteDoc
131
+
132
+ const { emitter, calls } = makeSpyEmitter({
133
+ PathParams: ok('public struct PathParams { public let id: String }', 'PathParams'),
134
+ Response: ok('public struct Response { public let id: String }', 'Response'),
135
+ NotFound: ok('public struct NotFound { public let message: String }', 'NotFound'),
136
+ })
137
+
138
+ emitSwiftRoute(route, emitter, new Map([['NotFound', { type: 'object' }]]), {
139
+ serializer: 'none',
140
+ accessLevel: 'public',
141
+ unsupportedUnions: 'fallback',
142
+ })
143
+
144
+ // 3 emits: PathParams, Response, NotFound
145
+ expect(calls.length).toBe(3)
146
+ for (const c of calls) {
147
+ expect(c.opts.inlineTypes).toBe(true)
148
+ expect(c.opts.serializer).toBe('none')
149
+ expect(c.opts.accessLevel).toBe('public')
150
+ expect(c.opts.unsupportedUnions).toBe('fallback')
151
+ }
152
+ })
153
+
154
+ it('preserves slot order: pathParams → query → body → response → errors', () => {
155
+ const route = {
156
+ kind: 'api',
157
+ name: 'X',
158
+ method: 'POST',
159
+ fullPath: '/x/:id',
160
+ schema: {
161
+ input: {
162
+ pathParams: { type: 'object' },
163
+ query: { type: 'object' },
164
+ body: { type: 'object' },
165
+ },
166
+ returnType: { type: 'object' },
167
+ },
168
+ errors: ['Z'],
169
+ } as unknown as AnyHttpRouteDoc
170
+
171
+ const { emitter, calls } = makeSpyEmitter({
172
+ PathParams: ok('a', 'PathParams'),
173
+ Query: ok('b', 'Query'),
174
+ Body: ok('c', 'Body'),
175
+ Response: ok('d', 'Response'),
176
+ Z: ok('e', 'Z'),
177
+ })
178
+
179
+ emitSwiftRoute(route, emitter, new Map([['Z', { type: 'object' }]]), {})
180
+
181
+ expect(calls.map((c) => c.opts.rootTypeName)).toEqual([
182
+ 'PathParams', 'Query', 'Body', 'Response', 'Z',
183
+ ])
184
+ })
185
+
186
+ it('threads passthrough opts (arrayItemNaming/depluralize/uncountableWords) verbatim', () => {
187
+ const route = {
188
+ kind: 'api',
189
+ name: 'X',
190
+ method: 'GET',
191
+ fullPath: '/x',
192
+ schema: { returnType: { type: 'object' } },
193
+ errors: [],
194
+ } as unknown as AnyHttpRouteDoc
195
+
196
+ const { emitter, calls } = makeSpyEmitter({
197
+ Response: ok('public struct Response: Codable { public let ok: Bool }', 'Response'),
198
+ })
199
+
200
+ emitSwiftRoute(route, emitter, new Map(), {
201
+ arrayItemNaming: false,
202
+ depluralize: true,
203
+ uncountableWords: ['data', 'metadata'],
204
+ })
205
+
206
+ expect(calls).toHaveLength(1)
207
+ expect(calls[0]!.opts.arrayItemNaming).toBe(false)
208
+ expect(calls[0]!.opts.depluralize).toBe(true)
209
+ expect(calls[0]!.opts.uncountableWords).toEqual(['data', 'metadata'])
210
+ })
211
+
212
+ it('does not include passthrough keys when caller omits them', () => {
213
+ const route = {
214
+ kind: 'api',
215
+ name: 'X',
216
+ method: 'GET',
217
+ fullPath: '/x',
218
+ schema: { returnType: { type: 'object' } },
219
+ errors: [],
220
+ } as unknown as AnyHttpRouteDoc
221
+
222
+ const { emitter, calls } = makeSpyEmitter({
223
+ Response: ok('public struct Response: Codable { public let ok: Bool }', 'Response'),
224
+ })
225
+
226
+ emitSwiftRoute(route, emitter, new Map(), {})
227
+
228
+ expect(calls).toHaveLength(1)
229
+ // Conditional-spread invariant: undefined opts must NOT be forwarded as keys
230
+ // (otherwise ajsc would receive `{ arrayItemNaming: undefined }` etc, which
231
+ // could shadow ajsc-side defaults).
232
+ expect('arrayItemNaming' in calls[0]!.opts).toBe(false)
233
+ expect('depluralize' in calls[0]!.opts).toBe(false)
234
+ expect('uncountableWords' in calls[0]!.opts).toBe(false)
235
+ // serializer / accessLevel / unsupportedUnions same invariant
236
+ expect('serializer' in calls[0]!.opts).toBe(false)
237
+ expect('accessLevel' in calls[0]!.opts).toBe(false)
238
+ expect('unsupportedUnions' in calls[0]!.opts).toBe(false)
239
+ // inlineTypes is always set
240
+ expect(calls[0]!.opts.inlineTypes).toBe(true)
241
+ })
242
+
243
+ it('defaults the static-member access level to public', () => {
244
+ const route = {
245
+ kind: 'api',
246
+ name: 'GetUser',
247
+ method: 'GET',
248
+ fullPath: '/users/:id',
249
+ schema: { input: { pathParams: { type: 'object' } } },
250
+ errors: [],
251
+ } as unknown as AnyHttpRouteDoc
252
+
253
+ const emitter = createStubSwiftEmitter({
254
+ PathParams: ok('public struct PathParams { public let id: String }', 'PathParams'),
255
+ })
256
+ const result = emitSwiftRoute(route, emitter, noErrors)
257
+ expect(result.code).toContain('public static let method = "GET"')
258
+ expect(result.code).toContain('public static let pathTemplate = "/users/{id}"')
259
+ expect(result.code).toContain('public static func path(_ p: PathParams) -> String')
260
+ })
261
+
262
+ it('honors accessLevel="internal" on the static members and the Errors enum', () => {
263
+ const route = {
264
+ kind: 'api',
265
+ name: 'GetUser',
266
+ method: 'GET',
267
+ fullPath: '/users/:id',
268
+ schema: { input: { pathParams: { type: 'object' } } },
269
+ errors: ['NotFound'],
270
+ } as unknown as AnyHttpRouteDoc
271
+
272
+ const emitter = createStubSwiftEmitter({
273
+ PathParams: ok('internal struct PathParams { internal let id: String }', 'PathParams'),
274
+ NotFound: ok('internal struct NotFound { internal let message: String }', 'NotFound'),
275
+ })
276
+ const result = emitSwiftRoute(
277
+ route,
278
+ emitter,
279
+ new Map([['NotFound', { type: 'object' }]]),
280
+ { accessLevel: 'internal' },
281
+ )
282
+ expect(result.code).toContain('internal static let method = "GET"')
283
+ expect(result.code).toContain('internal static let pathTemplate = "/users/{id}"')
284
+ expect(result.code).toContain('internal static func path(_ p: PathParams) -> String')
285
+ expect(result.code).toContain('internal enum Errors {')
286
+ })
287
+
288
+ it('emits accessLevel="internal" on the path constant for no-param routes', () => {
289
+ const route = {
290
+ kind: 'api',
291
+ name: 'CreateUser',
292
+ method: 'POST',
293
+ fullPath: '/users',
294
+ schema: {},
295
+ errors: [],
296
+ } as unknown as AnyHttpRouteDoc
297
+ const result = emitSwiftRoute(route, createStubSwiftEmitter({}), noErrors, { accessLevel: 'internal' })
298
+ expect(result.code).toContain('internal static let path = "/users"')
299
+ })
300
+ })
@@ -0,0 +1,90 @@
1
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
2
+ import type { SwiftEmitter, SwiftEmitOptions } from './ajsc-adapter.js'
3
+ import { indent } from '../_shared/indent.js'
4
+ import { pickDefined } from '../_shared/pick-defined.js'
5
+ import { toBracePath, pathParamNames } from '../_shared/path-utils.js'
6
+ import { extractRouteSlots } from '../_shared/route-slots.js'
7
+
8
+ export interface EmitRouteResult {
9
+ /** Inner body of the `enum RouteName { ... }` block — already indented one level. */
10
+ code: string
11
+ /** Imports collected from every ajsc emit + any helpers this route used. */
12
+ imports: string[]
13
+ /** Outer route name used as the `enum RouteName` identifier. */
14
+ routeName: string
15
+ /** True when the route was a stream (out-of-scope). Caller logs once. */
16
+ skipped?: boolean
17
+ }
18
+
19
+ /** Subset of SwiftEmitOptions threaded by the pipeline; per-call rootTypeName is set inside. */
20
+ export type EmitRouteOpts = Omit<SwiftEmitOptions, 'rootTypeName' | 'inlineTypes'>
21
+
22
+ function buildPathFn(bracePath: string, params: string[], access: string): string {
23
+ if (params.length === 0) return `${access} static let path = "${bracePath}"`
24
+ let body = bracePath
25
+ for (const name of params) body = body.replace(`{${name}}`, `\\(p.${name})`)
26
+ return `${access} static func path(_ p: PathParams) -> String { return "${body}" }`
27
+ }
28
+
29
+ function emitOptsFor(rootTypeName: string, routeOpts: EmitRouteOpts): SwiftEmitOptions {
30
+ const PASSTHROUGH_KEYS = ['serializer', 'accessLevel', 'unsupportedUnions', 'arrayItemNaming', 'depluralize', 'uncountableWords'] as const
31
+ return {
32
+ rootTypeName,
33
+ inlineTypes: true,
34
+ ...pickDefined(routeOpts, PASSTHROUGH_KEYS),
35
+ }
36
+ }
37
+
38
+ export function emitSwiftRoute(
39
+ route: AnyHttpRouteDoc,
40
+ emitter: SwiftEmitter,
41
+ errorSchemas: Map<string, unknown>,
42
+ routeOpts: EmitRouteOpts = {},
43
+ ): EmitRouteResult {
44
+ const kind = (route as { kind?: string }).kind
45
+ if (kind === 'stream') {
46
+ return { code: '', imports: [], routeName: route.name, skipped: true }
47
+ }
48
+
49
+ const isApi = kind === 'api' || 'fullPath' in route
50
+ const rawPath = isApi ? (route as { fullPath: string }).fullPath : (route as { path: string }).path
51
+ const method = String((route as { method: string }).method).toUpperCase()
52
+ const bracePath = toBracePath(rawPath)
53
+ const params = pathParamNames(rawPath)
54
+ const access = routeOpts.accessLevel ?? 'public'
55
+
56
+ const lines: string[] = [
57
+ `${access} static let method = "${method}"`,
58
+ `${access} static let pathTemplate = "${bracePath}"`,
59
+ buildPathFn(bracePath, params, access),
60
+ ]
61
+ const imports: string[] = []
62
+
63
+ // Per-slot emission. Order is fixed for deterministic output.
64
+ for (const slot of extractRouteSlots(route)) {
65
+ const result = emitter.emit(slot.source as Record<string, unknown>, emitOptsFor(slot.rootName, routeOpts))
66
+ lines.push('')
67
+ lines.push(result.code)
68
+ imports.push(...result.imports)
69
+ }
70
+
71
+ // Errors namespace — route.errors is `string[]` of taxonomy keys; look up each schema
72
+ // from the envelope-level errors map. Keys without schemas are skipped silently
73
+ // (matching the existing TS scope emitter's `errorKeys` filter).
74
+ const routeErrorKeys = ((route as { errors?: string[] }).errors ?? [])
75
+ .filter((key) => errorSchemas.has(key))
76
+ if (routeErrorKeys.length > 0) {
77
+ const inner: string[] = []
78
+ for (const key of routeErrorKeys) {
79
+ const r = emitter.emit(errorSchemas.get(key) as Record<string, unknown>, emitOptsFor(key, routeOpts))
80
+ inner.push(r.code)
81
+ imports.push(...r.imports)
82
+ }
83
+ lines.push('')
84
+ lines.push(`${access} enum Errors {`)
85
+ lines.push(indent(inner.join('\n\n'), 1))
86
+ lines.push('}')
87
+ }
88
+
89
+ return { code: lines.join('\n'), imports, routeName: route.name, skipped: false }
90
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
3
+ import type { ScopeGroup } from '../../group-routes.js'
4
+ import { emitSwiftScope } from './emit-scope-swift.js'
5
+ import { createStubSwiftEmitter, type SwiftEmitOptions, type SwiftEmitResult } from './ajsc-adapter.js'
6
+
7
+ const ok = (code: string, rootTypeName: string): SwiftEmitResult => ({
8
+ code,
9
+ rootTypeName,
10
+ extractedTypeNames: [],
11
+ imports: ['Foundation'],
12
+ })
13
+
14
+ describe('emitSwiftScope', () => {
15
+ it('produces a complete swift source file for a single-route scope', () => {
16
+ const route: AnyHttpRouteDoc = {
17
+ kind: 'api',
18
+ name: 'GetUser',
19
+ method: 'GET',
20
+ fullPath: '/users/:id',
21
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
22
+ errors: [],
23
+ } as unknown as AnyHttpRouteDoc
24
+
25
+ const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route] }
26
+ const emitter = createStubSwiftEmitter({
27
+ PathParams: ok('public struct PathParams: Codable { public let id: String }', 'PathParams'),
28
+ Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
29
+ })
30
+
31
+ const file = emitSwiftScope(group, { sourceHash: 'abc123' }, emitter, new Map())
32
+
33
+ expect(file.filename).toBe('Users.swift')
34
+ expect(file.code).toContain('// Generated by ts-procedures-codegen — do not edit.')
35
+ expect(file.code).toContain('// Source hash: abc123')
36
+ expect(file.code).toContain('import Foundation')
37
+ expect(file.code).toContain('public enum Users {')
38
+ expect(file.code).toContain('public enum GetUser {')
39
+ expect(file.code).toContain('public static let method = "GET"')
40
+ expect(file.code).not.toContain('package ')
41
+ })
42
+
43
+ it('joins multiple routes inside one scope enum, separated by a blank line', () => {
44
+ const route1 = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users/:id', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
45
+ const route2 = { kind: 'api', name: 'CreateUser', method: 'POST', fullPath: '/users', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
46
+ const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route1, route2] }
47
+ const emitter = createStubSwiftEmitter({})
48
+
49
+ const file = emitSwiftScope(group, { sourceHash: 'h' }, emitter, new Map())
50
+ expect(file.code).toContain('public enum GetUser {')
51
+ expect(file.code).toContain('public enum CreateUser {')
52
+ // exactly one outer scope enum
53
+ expect((file.code.match(/^public enum Users \{/gm) ?? []).length).toBe(1)
54
+ })
55
+
56
+ it('uses PascalCase scope name for the filename and outer enum', () => {
57
+ const group: ScopeGroup = { scopeKey: 'admin-users', camelCase: 'adminUsers', routes: [] }
58
+ const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
59
+ expect(file.filename).toBe('AdminUsers.swift')
60
+ expect(file.code).toContain('public enum AdminUsers {')
61
+ })
62
+
63
+ it('PascalCases multi-segment kebab scope keys', () => {
64
+ const group: ScopeGroup = { scopeKey: 'admin-settings', camelCase: 'adminSettings', routes: [] }
65
+ const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
66
+ expect(file.filename).toBe('AdminSettings.swift')
67
+ expect(file.code).toContain('public enum AdminSettings {')
68
+ })
69
+
70
+ it('emits an empty scope enum body when there are no non-stream routes', () => {
71
+ const group: ScopeGroup = { scopeKey: 'empty', camelCase: 'empty', routes: [] }
72
+ const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
73
+ expect(file.code).toContain('public enum Empty {\n}')
74
+ })
75
+
76
+ it('dedupes and sorts imports across all routes in the scope', () => {
77
+ const route1 = { kind: 'api', name: 'A', method: 'GET', fullPath: '/a', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
78
+ const route2 = { kind: 'api', name: 'B', method: 'GET', fullPath: '/b', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
79
+ const group: ScopeGroup = { scopeKey: 'multi', camelCase: 'multi', routes: [route1, route2] }
80
+ const emitter = {
81
+ emit(_s: unknown, opts: SwiftEmitOptions): SwiftEmitResult {
82
+ if (opts.rootTypeName === 'Response') {
83
+ return { code: `public struct Response { }`, rootTypeName: 'Response', extractedTypeNames: [], imports: ['Foundation', 'CommonCrypto'] }
84
+ }
85
+ throw new Error(`No stub for ${opts.rootTypeName}`)
86
+ },
87
+ }
88
+
89
+ const file = emitSwiftScope(group, { sourceHash: 'h' }, emitter, new Map())
90
+ // Should appear once per unique import, sorted alphabetically.
91
+ const imports = file.code.split('\n').filter((l) => l.startsWith('import '))
92
+ expect(imports).toEqual(['import CommonCrypto', 'import Foundation'])
93
+ })
94
+
95
+ it('threads all 6 passthrough opts to every emitter call', () => {
96
+ const route = { kind: 'api', name: 'X', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
97
+ const group: ScopeGroup = { scopeKey: 'x', camelCase: 'x', routes: [route] }
98
+
99
+ const calls: SwiftEmitOptions[] = []
100
+ const emitter = {
101
+ emit(_s: unknown, opts: SwiftEmitOptions) {
102
+ calls.push(opts)
103
+ return { code: 'public struct Response', rootTypeName: 'Response', extractedTypeNames: [], imports: [] }
104
+ },
105
+ }
106
+
107
+ emitSwiftScope(
108
+ group,
109
+ {
110
+ sourceHash: 'h',
111
+ serializer: 'none',
112
+ accessLevel: 'internal',
113
+ unsupportedUnions: 'fallback',
114
+ arrayItemNaming: false,
115
+ depluralize: true,
116
+ uncountableWords: ['data', 'metadata'],
117
+ },
118
+ emitter,
119
+ new Map(),
120
+ )
121
+
122
+ expect(calls.length).toBe(1)
123
+ expect(calls[0]!.inlineTypes).toBe(true)
124
+ expect(calls[0]!.serializer).toBe('none')
125
+ expect(calls[0]!.accessLevel).toBe('internal')
126
+ expect(calls[0]!.unsupportedUnions).toBe('fallback')
127
+ expect(calls[0]!.arrayItemNaming).toBe(false)
128
+ expect(calls[0]!.depluralize).toBe(true)
129
+ expect(calls[0]!.uncountableWords).toEqual(['data', 'metadata'])
130
+ })
131
+
132
+ it('applies accessLevel to the outer scope enum and the route enum wrappers', () => {
133
+ const route = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
134
+ const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route] }
135
+ const emitter = createStubSwiftEmitter({})
136
+
137
+ const file = emitSwiftScope(group, { sourceHash: 'h', accessLevel: 'internal' }, emitter, new Map())
138
+ expect(file.code).toContain('internal enum Users {')
139
+ expect(file.code).toContain('internal enum GetUser {')
140
+ expect(file.code).toContain('internal static let method = "GET"')
141
+ expect(file.code).not.toContain('public enum')
142
+ })
143
+
144
+ it('collects skipped stream-route names', () => {
145
+ const stream = { kind: 'stream', name: 'WatchUsers', method: 'GET', path: '/u/stream', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
146
+ const api = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
147
+ const group: ScopeGroup = { scopeKey: 'u', camelCase: 'u', routes: [stream, api] }
148
+
149
+ const emitter = createStubSwiftEmitter({
150
+ Response: ok('public struct Response: Codable { public let id: String }', 'Response'),
151
+ })
152
+ const result = emitSwiftScope(group, { sourceHash: 'h' }, emitter, new Map())
153
+
154
+ expect(result.skippedStreams).toEqual(['WatchUsers'])
155
+ expect(result.code).toContain('public enum GetUser')
156
+ expect(result.code).not.toContain('public enum WatchUsers')
157
+ })
158
+
159
+ it('terminates the file with a trailing newline', () => {
160
+ const group: ScopeGroup = { scopeKey: 'x', camelCase: 'x', routes: [] }
161
+ const file = emitSwiftScope(group, { sourceHash: 'h' }, createStubSwiftEmitter({}), new Map())
162
+ expect(file.code.endsWith('\n')).toBe(true)
163
+ })
164
+ })
@@ -0,0 +1,59 @@
1
+ import type { ScopeGroup } from '../../group-routes.js'
2
+ import type { SwiftEmitter } from './ajsc-adapter.js'
3
+ import { emitSwiftRoute, type EmitRouteOpts } from './emit-route-swift.js'
4
+ import { swiftHeader, swiftImports } from './format-swift.js'
5
+ import { indent } from '../_shared/indent.js'
6
+ import { pickDefined } from '../_shared/pick-defined.js'
7
+ import { pascalCase } from '../_shared/pascal-case.js'
8
+
9
+ export interface EmitScopeOptions extends EmitRouteOpts {
10
+ sourceHash: string
11
+ }
12
+
13
+ export interface EmittedSwiftFile {
14
+ filename: string
15
+ code: string
16
+ /** Names of stream routes within this scope that were skipped. */
17
+ skippedStreams: string[]
18
+ }
19
+
20
+ export function emitSwiftScope(
21
+ group: ScopeGroup,
22
+ opts: EmitScopeOptions,
23
+ emitter: SwiftEmitter,
24
+ errorSchemas: Map<string, unknown>,
25
+ ): EmittedSwiftFile {
26
+ const scopeName = pascalCase(group.scopeKey)
27
+ const access = opts.accessLevel ?? 'public'
28
+ const allImports: string[] = []
29
+ const routeBlocks: string[] = []
30
+ const skippedStreams: string[] = []
31
+
32
+ const PASSTHROUGH_KEYS = ['serializer', 'accessLevel', 'unsupportedUnions', 'arrayItemNaming', 'depluralize', 'uncountableWords'] as const
33
+ const routeOpts: EmitRouteOpts = pickDefined(opts, PASSTHROUGH_KEYS)
34
+
35
+ for (const route of group.routes) {
36
+ const r = emitSwiftRoute(route, emitter, errorSchemas, routeOpts)
37
+ if (r.skipped) {
38
+ skippedStreams.push(r.routeName)
39
+ continue
40
+ }
41
+ allImports.push(...r.imports)
42
+ const wrapped = `${access} enum ${r.routeName} {\n${indent(r.code, 1)}\n}`
43
+ routeBlocks.push(wrapped)
44
+ }
45
+
46
+ const innerScope = routeBlocks.length === 0 ? '' : indent(routeBlocks.join('\n\n'), 1)
47
+ const scopeBlock = innerScope === ''
48
+ ? `${access} enum ${scopeName} {\n}`
49
+ : `${access} enum ${scopeName} {\n${innerScope}\n}`
50
+
51
+ const importsBlock = swiftImports(allImports)
52
+ const parts = [
53
+ swiftHeader(opts.sourceHash),
54
+ importsBlock,
55
+ scopeBlock,
56
+ ].filter((p) => p.length > 0)
57
+
58
+ return { filename: `${scopeName}.swift`, code: parts.join('\n\n') + '\n', skippedStreams }
59
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ swiftHeader,
4
+ swiftImports,
5
+ } from './format-swift.js'
6
+
7
+ describe('format-swift', () => {
8
+ it('emits a two-line generated header with source hash', () => {
9
+ expect(swiftHeader('abc123')).toBe(
10
+ '// Generated by ts-procedures-codegen — do not edit.\n// Source hash: abc123',
11
+ )
12
+ })
13
+
14
+ it('dedupes and sorts imports', () => {
15
+ expect(swiftImports(['Foundation', 'Foundation', 'CommonCrypto'])).toBe(
16
+ 'import CommonCrypto\nimport Foundation',
17
+ )
18
+ })
19
+
20
+ it('returns empty string when no imports', () => {
21
+ expect(swiftImports([])).toBe('')
22
+ })
23
+ })
@@ -0,0 +1,9 @@
1
+ export function swiftHeader(sourceHash: string): string {
2
+ return `// Generated by ts-procedures-codegen — do not edit.\n// Source hash: ${sourceHash}`
3
+ }
4
+
5
+ export function swiftImports(imports: string[]): string {
6
+ if (imports.length === 0) return ''
7
+ const unique = Array.from(new Set(imports)).sort()
8
+ return unique.map((i) => `import ${i}`).join('\n')
9
+ }