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,77 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { runPipeline } from '../../pipeline.js'
6
+ import { assertGoldenOrUpdate } from '../../test-helpers/golden.js'
7
+ import { createStubKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
8
+
9
+ const __filename = fileURLToPath(import.meta.url)
10
+ const __dirname = dirname(__filename)
11
+
12
+ const ok = (code: string, rootTypeName: string, imports: string[] = ['kotlinx.serialization.Serializable']): KotlinEmitResult => ({
13
+ code,
14
+ rootTypeName,
15
+ extractedTypeNames: [],
16
+ imports,
17
+ })
18
+
19
+ describe('kotlin codegen — integration', () => {
20
+ it('produces byte-identical output against the golden fixture', async () => {
21
+ const envelopePath = join(__dirname, '../../__fixtures__/users-envelope.json')
22
+ const goldenPath = join(__dirname, '__fixtures__/users-golden.kt')
23
+ const envelope = JSON.parse(await readFile(envelopePath, 'utf8'))
24
+
25
+ // Hand-authored slot outputs in the v7.2 nested-class shape (inlineTypes: true).
26
+ //
27
+ // The stub map is keyed on rootTypeName, NOT on (route, slot). Both GetUser
28
+ // and CreateUser have a slot named "Response" — they intentionally share the
29
+ // single stub entry below. The golden file therefore shows three identical
30
+ // Response data classes (one per route that has one). This is correct: the
31
+ // integration test pins our file-assembly logic, not real ajsc per-route
32
+ // output. The kotlinc E2E (Task 11) exercises real ajsc against the same
33
+ // fixture and would surface any incompatibility.
34
+ const emitter = createStubKotlinEmitter({
35
+ // GetUser
36
+ PathParams: ok('@Serializable\ndata class PathParams(\n val id: String,\n)', 'PathParams'),
37
+ Response: ok(
38
+ '@Serializable\ndata class Response(\n val id: String,\n val name: String,\n @SerialName("created-at") @Contextual val createdAt: java.time.Instant,\n val address: Address,\n) {\n @Serializable\n data class Address(\n val street: String,\n val city: String,\n )\n}',
39
+ 'Response',
40
+ ['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.Contextual'],
41
+ ),
42
+ NotFound: ok(
43
+ '@Serializable\ndata class NotFound(\n val name: String = "NotFound",\n val message: String,\n)',
44
+ 'NotFound',
45
+ ),
46
+
47
+ // CreateUser
48
+ Body: ok(
49
+ '@Serializable\n@JsonClassDiscriminator("kind")\nsealed interface Body {\n @Serializable\n @SerialName("guest")\n data class GuestBody(\n val displayName: String,\n ) : Body\n\n @Serializable\n @SerialName("registered")\n data class RegisteredBody(\n val email: String,\n val name: String,\n ) : Body\n}',
50
+ 'Body',
51
+ ['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.json.JsonClassDiscriminator'],
52
+ ),
53
+ ValidationError: ok(
54
+ '@Serializable\ndata class ValidationError(\n val name: String = "ValidationError",\n val message: String,\n val field: String? = null,\n)',
55
+ 'ValidationError',
56
+ ),
57
+
58
+ // ListUsers
59
+ Query: ok(
60
+ '@Serializable\ndata class Query(\n val status: Status? = null,\n val limit: Long? = null,\n) {\n @Serializable\n enum class Status {\n @SerialName("active") ACTIVE,\n @SerialName("inactive") INACTIVE,\n }\n}',
61
+ 'Query',
62
+ ['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName'],
63
+ ),
64
+ })
65
+
66
+ const files = await runPipeline({
67
+ envelope, outDir: 'out', dryRun: true,
68
+ target: 'kotlin', kotlinPackage: 'com.example.api',
69
+ kotlinEmitter: emitter,
70
+ })
71
+
72
+ expect(files).toHaveLength(1)
73
+ expect(files[0]!.path).toBe(join('out', 'Users.kt'))
74
+
75
+ await assertGoldenOrUpdate(files[0]!.code, goldenPath)
76
+ })
77
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+
3
+ let ajscResolvable = false
4
+ let emitKotlinFn:
5
+ | ((
6
+ schema: unknown,
7
+ opts: unknown,
8
+ ) => { code: string; imports: string[]; rootTypeName: string; extractedTypeNames: string[] })
9
+ | undefined
10
+
11
+ try {
12
+ const ajsc = await import('ajsc')
13
+ if (typeof (ajsc as { emitKotlin?: unknown }).emitKotlin === 'function') {
14
+ ajscResolvable = true
15
+ emitKotlinFn = (ajsc as { emitKotlin: typeof emitKotlinFn }).emitKotlin!
16
+ }
17
+ } catch {
18
+ // ajsc not installed (e.g. npm install --omit=optional); test skips below.
19
+ }
20
+
21
+ describe('ajsc.emitKotlin — untagged oneOf behavior', () => {
22
+ it.skipIf(!ajscResolvable)(
23
+ 'produces a deterministic fallback shape for an untagged oneOf',
24
+ () => {
25
+ const schema = {
26
+ oneOf: [{ type: 'string' }, { type: 'integer' }],
27
+ }
28
+ const result = emitKotlinFn!(schema, {
29
+ rootTypeName: 'Mixed',
30
+ inlineTypes: true,
31
+ unsupportedUnions: 'fallback',
32
+ })
33
+
34
+ // Snapshot pins the current ajsc behavior. If ajsc changes the fallback
35
+ // shape, this diff prompts an intentional review and a corresponding
36
+ // update to docs/codegen-kotlin.md.
37
+ expect({
38
+ code: result.code,
39
+ imports: result.imports.slice().sort(),
40
+ rootTypeName: result.rootTypeName,
41
+ extractedTypeNames: result.extractedTypeNames.slice().sort(),
42
+ }).toMatchSnapshot()
43
+ },
44
+ )
45
+
46
+ // Companion to test 1: confirms 'fallback' is not load-bearing — ajsc emits the
47
+ // same shape with or without it. If these snapshots ever diverge, that's a
48
+ // meaningful behavior change worth surfacing in docs/codegen-kotlin.md.
49
+ it.skipIf(!ajscResolvable)(
50
+ 'silently falls back to empty data class when unsupportedUnions is not specified',
51
+ () => {
52
+ const schema = { oneOf: [{ type: 'string' }, { type: 'integer' }] }
53
+ const result = emitKotlinFn!(schema, { rootTypeName: 'Mixed', inlineTypes: true })
54
+ // When unsupportedUnions defaults (not explicitly set), ajsc does NOT throw.
55
+ // Instead it silently emits an empty data class.
56
+ expect({
57
+ code: result.code,
58
+ imports: result.imports.slice().sort(),
59
+ rootTypeName: result.rootTypeName,
60
+ extractedTypeNames: result.extractedTypeNames.slice().sort(),
61
+ }).toMatchSnapshot()
62
+ },
63
+ )
64
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Kotlin-target pipeline runner. Invoked by the dispatcher in
3
+ * `src/codegen/pipeline.ts` when `target === 'kotlin'`. Emits one `.kt` file
4
+ * per scope via `emitKotlinScope` and commits via the shared
5
+ * `writeGeneratedFiles` tail.
6
+ */
7
+ import { join } from 'node:path'
8
+ import type { GeneratedFile } from '../_shared/write-files.js'
9
+ import { writeGeneratedFiles } from '../_shared/write-files.js'
10
+ import { buildErrorSchemasMap } from '../_shared/error-schemas.js'
11
+ import { pickDefined } from '../_shared/pick-defined.js'
12
+ import type { TargetRunInput } from '../_shared/target-run.js'
13
+ import type { KotlinEmitter } from './ajsc-adapter.js'
14
+ import { emitKotlinScope } from './emit-scope-kotlin.js'
15
+
16
+ export interface KotlinRunInput extends TargetRunInput {
17
+ kotlinPackage?: string
18
+ kotlinSerializer?: 'kotlinx' | 'none'
19
+ unsupportedUnions?: 'throw' | 'fallback'
20
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
21
+ kotlinEmitter?: KotlinEmitter
22
+ }
23
+
24
+ /**
25
+ * Spec §"CLI flags" — `--array-item-naming`, `--depluralize`,
26
+ * `--uncountable-words` also apply to the Kotlin target. The CLI parks them
27
+ * on `options.ajsc` (a TS-target structure historically); copy the
28
+ * Kotlin-relevant subset onto the scope opts here.
29
+ */
30
+ const AJSC_PASSTHROUGH_KEYS = ['arrayItemNaming', 'depluralize', 'uncountableWords'] as const
31
+
32
+ export async function runKotlinPipeline(input: KotlinRunInput): Promise<GeneratedFile[]> {
33
+ const { envelope, outDir, hash, groups, dryRun = false, cleanOutDir = false } = input
34
+
35
+ // Runtime guards for non-CLI callers (direct API consumers + tests).
36
+ // The CLI validates --kotlin-package in parseArgs and resolves the emitter
37
+ // before calling runPipeline, so end users never hit these throws.
38
+ if (input.kotlinPackage == null) {
39
+ throw new Error('[ts-procedures-codegen] target=kotlin requires kotlinPackage')
40
+ }
41
+ if (input.kotlinEmitter == null) {
42
+ throw new Error(
43
+ '[ts-procedures-codegen] target=kotlin requires a kotlinEmitter (CLI resolves via resolveProductionKotlinEmitter)'
44
+ )
45
+ }
46
+
47
+ const errorSchemas = buildErrorSchemasMap(envelope.errors)
48
+ const ajscPassthrough = input.ajsc ?? {}
49
+
50
+ const files: GeneratedFile[] = []
51
+ const allSkipped: string[] = []
52
+ for (const group of groups) {
53
+ const emitted = emitKotlinScope(
54
+ group,
55
+ {
56
+ kotlinPackage: input.kotlinPackage,
57
+ sourceHash: hash,
58
+ ...(input.kotlinSerializer !== undefined ? { serializer: input.kotlinSerializer } : {}),
59
+ ...(input.unsupportedUnions !== undefined ? { unsupportedUnions: input.unsupportedUnions } : {}),
60
+ ...pickDefined(ajscPassthrough, AJSC_PASSTHROUGH_KEYS),
61
+ },
62
+ input.kotlinEmitter,
63
+ errorSchemas,
64
+ )
65
+ files.push({ path: join(outDir, emitted.filename), code: emitted.code })
66
+ allSkipped.push(...emitted.skippedStreams)
67
+ }
68
+
69
+ if (allSkipped.length > 0) {
70
+ console.log(
71
+ `[ts-procedures-codegen] Skipped ${allSkipped.length} stream route${allSkipped.length === 1 ? '' : 's'} (kotlin target): ${allSkipped.join(', ')}`,
72
+ )
73
+ }
74
+
75
+ await writeGeneratedFiles(files, outDir, { dryRun, cleanOutDir })
76
+
77
+ return files
78
+ }
@@ -0,0 +1,123 @@
1
+ // Generated by ts-procedures-codegen — do not edit.
2
+ // Source hash: <PLACEHOLDER>
3
+
4
+ import Foundation
5
+
6
+ public enum Users {
7
+ public enum GetUser {
8
+ public static let method = "GET"
9
+ public static let pathTemplate = "/users/{id}"
10
+ public static func path(_ p: PathParams) -> String { return "/users/\(p.id)" }
11
+
12
+ public struct PathParams: Codable {
13
+ public let id: String
14
+ }
15
+
16
+ public struct Response: Codable {
17
+ public let id: String
18
+ public let name: String
19
+ public let createdAt: Date
20
+ public let address: Address
21
+
22
+ enum CodingKeys: String, CodingKey {
23
+ case id, name
24
+ case createdAt = "created-at"
25
+ case address
26
+ }
27
+
28
+ public struct Address: Codable {
29
+ public let street: String
30
+ public let city: String
31
+ }
32
+ }
33
+
34
+ public enum Errors {
35
+ public struct NotFound: Codable {
36
+ public let name: String
37
+ public let message: String
38
+ }
39
+ }
40
+ }
41
+
42
+ public enum CreateUser {
43
+ public static let method = "POST"
44
+ public static let pathTemplate = "/users"
45
+ public static let path = "/users"
46
+
47
+ public enum Body: Codable {
48
+ case guest(GuestBody)
49
+ case registered(RegisteredBody)
50
+
51
+ public struct GuestBody: Codable {
52
+ public let kind: String
53
+ public let displayName: String
54
+ }
55
+
56
+ public struct RegisteredBody: Codable {
57
+ public let kind: String
58
+ public let email: String
59
+ public let name: String
60
+ }
61
+ }
62
+
63
+ public struct Response: Codable {
64
+ public let id: String
65
+ public let name: String
66
+ public let createdAt: Date
67
+ public let address: Address
68
+
69
+ enum CodingKeys: String, CodingKey {
70
+ case id, name
71
+ case createdAt = "created-at"
72
+ case address
73
+ }
74
+
75
+ public struct Address: Codable {
76
+ public let street: String
77
+ public let city: String
78
+ }
79
+ }
80
+
81
+ public enum Errors {
82
+ public struct ValidationError: Codable {
83
+ public let name: String
84
+ public let message: String
85
+ public let field: String?
86
+ }
87
+ }
88
+ }
89
+
90
+ public enum ListUsers {
91
+ public static let method = "GET"
92
+ public static let pathTemplate = "/users"
93
+ public static let path = "/users"
94
+
95
+ public struct Query: Codable {
96
+ public let status: Status?
97
+ public let limit: Int64?
98
+
99
+ public enum Status: String, Codable {
100
+ case active
101
+ case inactive
102
+ }
103
+ }
104
+
105
+ public struct Response: Codable {
106
+ public let id: String
107
+ public let name: String
108
+ public let createdAt: Date
109
+ public let address: Address
110
+
111
+ enum CodingKeys: String, CodingKey {
112
+ case id, name
113
+ case createdAt = "created-at"
114
+ case address
115
+ }
116
+
117
+ public struct Address: Codable {
118
+ public let street: String
119
+ public let city: String
120
+ }
121
+ }
122
+ }
123
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { DocEnvelope } from '../../../implementations/types.js'
3
+ import { runPipeline } from '../../pipeline.js'
4
+ import { resolveProductionSwiftEmitter } from './ajsc-adapter.js'
5
+
6
+ /**
7
+ * End-to-end regression for `--swift-access-level`.
8
+ *
9
+ * Closes a coverage gap: existing unit tests verify our scope/route
10
+ * scaffolding (the outer `enum` keywords) honor the access level, but
11
+ * nothing asserts that real ajsc actually emits `internal struct` /
12
+ * `internal let` in its own output. If ajsc renamed `accessLevel` or
13
+ * changed its semantics, our codegen would silently regress to `public`.
14
+ *
15
+ * This test runs the production pipeline with the real ajsc emitter
16
+ * and pins the keyword presence in the generated source. No toolchain
17
+ * (no swiftc) is required — we only assert string contents.
18
+ */
19
+ const envelope: DocEnvelope = {
20
+ basePath: '/api',
21
+ headers: [],
22
+ errors: [],
23
+ routes: [
24
+ {
25
+ kind: 'api',
26
+ name: 'GetThing',
27
+ scope: 'things',
28
+ method: 'get',
29
+ path: '/things/:id',
30
+ fullPath: '/things/:id',
31
+ jsonSchema: {},
32
+ // The swift route emitter reads `route.schema.input.*` and
33
+ // `route.schema.returnType` directly; mirror what the codegen
34
+ // pipeline downstream expects rather than the doc-builder
35
+ // `jsonSchema` shape.
36
+ schema: {
37
+ input: {
38
+ pathParams: {
39
+ type: 'object',
40
+ properties: { id: { type: 'string' } },
41
+ required: ['id'],
42
+ },
43
+ },
44
+ returnType: {
45
+ type: 'object',
46
+ properties: { id: { type: 'string' } },
47
+ required: ['id'],
48
+ },
49
+ },
50
+ errors: [],
51
+ } as unknown as DocEnvelope['routes'][number],
52
+ ],
53
+ }
54
+
55
+ describe('swift codegen — --swift-access-level (real ajsc)', () => {
56
+ it('emits `internal` keywords on ajsc-generated structs when accessLevel="internal"', async () => {
57
+ const emitter = await resolveProductionSwiftEmitter()
58
+ const files = await runPipeline({
59
+ envelope,
60
+ outDir: 'out',
61
+ dryRun: true,
62
+ target: 'swift',
63
+ swiftAccessLevel: 'internal',
64
+ swiftEmitter: emitter,
65
+ })
66
+
67
+ expect(files).toHaveLength(1)
68
+ const code = files[0]!.code
69
+
70
+ // Real ajsc output for the PathParams + Response slots.
71
+ expect(code).toContain('internal struct PathParams')
72
+ expect(code).toContain('internal struct Response')
73
+ // Property declarations should also flip to internal.
74
+ expect(code).toContain('internal let id: String')
75
+
76
+ // Our scope/route scaffolding flips too — see emit-scope-swift.ts
77
+ // and emit-route-swift.ts, which both consume `accessLevel`.
78
+ expect(code).toContain('internal enum Things')
79
+ expect(code).toContain('internal enum GetThing')
80
+
81
+ // Hard guarantee: nothing in the file leaks `public` declarations.
82
+ // If ajsc silently ignored `accessLevel: 'internal'`, this would fail.
83
+ expect(code).not.toMatch(/\bpublic\s+(struct|enum|let|var|func|static)\b/)
84
+ })
85
+
86
+ it('emits `public` keywords on ajsc-generated structs when accessLevel defaults to public', async () => {
87
+ const emitter = await resolveProductionSwiftEmitter()
88
+ const files = await runPipeline({
89
+ envelope,
90
+ outDir: 'out',
91
+ dryRun: true,
92
+ target: 'swift',
93
+ // intentionally omit swiftAccessLevel — default is 'public'
94
+ swiftEmitter: emitter,
95
+ })
96
+
97
+ expect(files).toHaveLength(1)
98
+ const code = files[0]!.code
99
+
100
+ expect(code).toContain('public struct PathParams')
101
+ expect(code).toContain('public struct Response')
102
+ expect(code).toContain('public enum Things')
103
+ expect(code).toContain('public enum GetThing')
104
+
105
+ // No `internal` declarations should appear when access level is public.
106
+ expect(code).not.toMatch(/\binternal\s+(struct|enum|let|var|func|static)\b/)
107
+ })
108
+ })
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createStubSwiftEmitter, resolveProductionSwiftEmitter, type SwiftEmitResult } from './ajsc-adapter.js'
3
+
4
+ describe('createStubSwiftEmitter', () => {
5
+ it('returns the configured EmitResult for the matching root name', () => {
6
+ const expected: SwiftEmitResult = {
7
+ code: 'public struct User: Codable { public let id: String }',
8
+ rootTypeName: 'User',
9
+ extractedTypeNames: [],
10
+ imports: ['Foundation'],
11
+ }
12
+ const emitter = createStubSwiftEmitter({ User: expected })
13
+ expect(
14
+ emitter.emit(
15
+ { type: 'object' },
16
+ {
17
+ rootTypeName: 'User',
18
+ inlineTypes: true,
19
+ serializer: 'codable',
20
+ unsupportedUnions: 'throw',
21
+ },
22
+ ),
23
+ ).toEqual(expected)
24
+ })
25
+
26
+ it('throws when asked to emit a name not in the stub map', () => {
27
+ const emitter = createStubSwiftEmitter({})
28
+ expect(() => emitter.emit({}, { rootTypeName: 'Missing' })).toThrow(/Missing/)
29
+ })
30
+ })
31
+
32
+ describe('resolveProductionSwiftEmitter', () => {
33
+ it('returns a working emitter that invokes ajsc.emitSwift when ajsc is installed', async () => {
34
+ const emitter = await resolveProductionSwiftEmitter()
35
+ const result = emitter.emit(
36
+ { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
37
+ { rootTypeName: 'Probe' },
38
+ )
39
+ expect(typeof result.code).toBe('string')
40
+ expect(result.code.length).toBeGreaterThan(0)
41
+ expect(result.rootTypeName).toBe('Probe')
42
+ expect(Array.isArray(result.imports)).toBe(true)
43
+ })
44
+ // Note: testing the failure path (ajsc unavailable) requires module mocking;
45
+ // we leave that as a manual-verification path. The error message is pinned
46
+ // by the message text below so any change requires updating both call sites.
47
+ })
@@ -0,0 +1,67 @@
1
+ export interface SwiftEmitResult {
2
+ code: string
3
+ rootTypeName: string
4
+ extractedTypeNames: string[]
5
+ imports: string[]
6
+ }
7
+
8
+ export interface SwiftEmitOptions {
9
+ rootTypeName: string
10
+ /** Always set true at our call sites; v7.2 default is false. */
11
+ inlineTypes?: boolean
12
+ serializer?: 'codable' | 'none'
13
+ accessLevel?: 'public' | 'internal'
14
+ unsupportedUnions?: 'throw' | 'fallback'
15
+ arrayItemNaming?: string | false
16
+ depluralize?: boolean
17
+ uncountableWords?: string[]
18
+ }
19
+
20
+ export interface SwiftEmitter {
21
+ emit(schema: Record<string, unknown>, opts: SwiftEmitOptions): SwiftEmitResult
22
+ }
23
+
24
+ export function createStubSwiftEmitter(
25
+ results: Record<string, SwiftEmitResult>,
26
+ ): SwiftEmitter {
27
+ return {
28
+ emit(_schema, opts) {
29
+ const result = results[opts.rootTypeName]
30
+ if (result == null) {
31
+ throw new Error(
32
+ `[stub-swift-emitter] No stubbed result for rootTypeName "${opts.rootTypeName}". ` +
33
+ `Provide one in the results map.`,
34
+ )
35
+ }
36
+ return result
37
+ },
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Resolves the production Swift emitter from `ajsc`. Throws a clear error
43
+ * if ajsc is not installed or does not expose `emitSwift` (e.g. consumer
44
+ * ran `npm install --omit=optional` since ajsc is in optionalDependencies).
45
+ */
46
+ export async function resolveProductionSwiftEmitter(): Promise<SwiftEmitter> {
47
+ let ajsc: { emitSwift?: unknown } | null = null
48
+ let importError: unknown
49
+ try {
50
+ ajsc = (await import('ajsc')) as { emitSwift?: unknown }
51
+ } catch (err) {
52
+ importError = err
53
+ }
54
+ const emitSwift = ajsc?.emitSwift
55
+ if (typeof emitSwift !== 'function') {
56
+ throw new Error(
57
+ '[ts-procedures-codegen] ajsc.emitSwift is not available. ' +
58
+ 'Install ajsc (`npm install ajsc`) — it is an optional dependency.',
59
+ importError !== undefined ? { cause: importError } : undefined,
60
+ )
61
+ }
62
+ return {
63
+ emit(schema, opts) {
64
+ return (emitSwift as (s: unknown, o: unknown) => SwiftEmitResult)(schema, opts)
65
+ },
66
+ }
67
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { execFileSync, execSync } from 'node:child_process'
3
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { runPipeline } from '../../pipeline.js'
8
+ import { resolveProductionSwiftEmitter } from './ajsc-adapter.js'
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = dirname(__filename)
12
+
13
+ function swiftcAvailable(): boolean {
14
+ try {
15
+ execSync('swiftc --version', { stdio: 'ignore' })
16
+ return true
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ const RUN = process.env.TS_PROCEDURES_SWIFT_E2E === '1'
23
+
24
+ /**
25
+ * E2E: real ajsc → real .swift output → real swiftc parse.
26
+ *
27
+ * Gated on (a) `swiftc` on PATH and (b) opt-in via env var so default
28
+ * `npm test` runs stay green for contributors without the toolchain.
29
+ *
30
+ * Unlike Kotlin, Swift's stdlib + Foundation already ship with `Codable`,
31
+ * so no extra classpath/jars are needed — `swiftc -parse` against a single
32
+ * file is sufficient to validate the output is syntactically well-formed.
33
+ */
34
+ describe('swift codegen — swiftc compile (gated)', () => {
35
+ it.skipIf(!swiftcAvailable() || !RUN)(
36
+ 'parses generated output without errors',
37
+ async () => {
38
+ const emitter = await resolveProductionSwiftEmitter()
39
+ const envelope = JSON.parse(
40
+ readFileSync(join(__dirname, '../../__fixtures__/users-envelope.json'), 'utf8'),
41
+ )
42
+ const files = await runPipeline({
43
+ envelope,
44
+ outDir: 'out',
45
+ dryRun: true,
46
+ target: 'swift',
47
+ swiftEmitter: emitter,
48
+ })
49
+
50
+ const dir = mkdtempSync(join(tmpdir(), 'tsp-swift-e2e-'))
51
+ const filePaths: string[] = []
52
+ for (const f of files) {
53
+ const dest = join(dir, f.path.split('/').pop()!)
54
+ writeFileSync(dest, f.code)
55
+ filePaths.push(dest)
56
+ }
57
+
58
+ // -parse only checks syntax; no codegen, no linking, no module deps.
59
+ execFileSync('swiftc', ['-parse', ...filePaths], { stdio: 'inherit' })
60
+
61
+ // Sanity check — at least one .swift file was generated and persisted.
62
+ expect(filePaths.length).toBeGreaterThan(0)
63
+ for (const p of filePaths) expect(existsSync(p)).toBe(true)
64
+ },
65
+ )
66
+ })