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,1993 @@
1
+ # ajsc v7.2 Kotlin Codegen Polish Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Polish the in-progress Kotlin codegen target to align with `ajsc@^7.2.0`: hardcode `inlineTypes: true` so cross-slot dedup falls out of nested-class scoping (no shared registry plumbing), trim dead options from the adapter contract, surface `--kotlin-serializer` and `--unsupported-unions` CLI flags, replace the trivial test fixture with one that exercises real ajsc v7.2 features, activate the previously-gated kotlinc E2E test, and ship a downstream-consumer setup guide.
6
+
7
+ **Architecture:** ajsc emits **declarations only** (no `package`, no `import`); `ts-procedures` assembles `.kt` files (package line, deduped imports, scope/route wrappers, source-hash header). Per-route emission becomes a pure pipeline — no `Set<string>` shared across `emit` calls — because Kotlin's nested-class scoping makes `Body.Address` and `Response.Address` different fully-qualified names by construction. Two new pipeline opts thread through unchanged: `kotlinSerializer` (`'kotlinx' | 'none'`) and `unsupportedUnions` (`'throw' | 'fallback'`).
8
+
9
+ **Tech Stack:** TypeScript, Vitest, Node.js, `ajsc@^7.2.0` (in `optionalDependencies`). E2E compile test uses `kotlinc` and `kotlinx-serialization-json` jar (gated on local env opt-in).
10
+
11
+ **Spec:** [`docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md`](../specs/2026-04-25-ajsc-v7-kotlin-polish-design.md)
12
+
13
+ **Branch context:** This plan executes on the `codegen-kotlin-swift` branch, which already contains a Phase B Kotlin codegen implementation (per [`docs/superpowers/plans/2026-04-24-kotlin-codegen-target.md`](./2026-04-24-kotlin-codegen-target.md)). Most files exist; this plan **modifies** them.
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ | Path | Change | Responsibility |
20
+ |---|---|---|
21
+ | `src/codegen/targets/kotlin/ajsc-adapter.ts` | Modify | Drop `enumStyle`, add `serializer`/`unsupportedUnions`/`inlineTypes` on `KotlinEmitOptions`; clean up production resolver TODO. |
22
+ | `src/codegen/targets/kotlin/ajsc-adapter.test.ts` | Modify | Drop dead-option asserts; pin production resolver error-message text. |
23
+ | `src/codegen/targets/kotlin/emit-route-kotlin.ts` | Modify | Take new opts arg; pass `inlineTypes: true` + `serializer` + `unsupportedUnions` to every `emitter.emit`; replace inline `console.warn` with `skipped: true` return flag. |
24
+ | `src/codegen/targets/kotlin/emit-route-kotlin.test.ts` | Modify | Assert opts threaded per slot; assert `skipped: true` on stream routes; assert slot ordering. |
25
+ | `src/codegen/targets/kotlin/emit-scope-kotlin.ts` | Modify | Take new opts arg; thread to route emitter; collect `skippedStreams` and surface in return. |
26
+ | `src/codegen/targets/kotlin/emit-scope-kotlin.test.ts` | Modify | Assert opts threaded; assert skipped-streams collection. |
27
+ | `src/codegen/pipeline.ts` | Modify | Add `kotlinSerializer` + `unsupportedUnions` to `PipelineOptions`; thread through; aggregate `skippedStreams` and emit single summary log. |
28
+ | `src/codegen/pipeline.test.ts` | Modify | Tests for new opts threading; test for stream summary log. |
29
+ | `src/codegen/index.ts` | Modify | Surface new opts on `GenerateClientOptions`; pass through to `runPipeline`. |
30
+ | `src/codegen/bin/cli.ts` | Modify | Add `--kotlin-serializer` and `--unsupported-unions` flag parsing; place in `parsed.kotlin.serializer` and `parsed.unsupportedUnions`; print setup-guide pointer after successful Kotlin run. |
31
+ | `src/codegen/bin/cli.test.ts` | Modify | Tests for both new flags (CLI, config, override, invalid value); test for printed pointer. |
32
+ | `src/codegen/targets/kotlin/__fixtures__/users-envelope.json` | Modify | Replace placeholder envelope with realistic schemas (3 routes, nested objects, discriminated `oneOf`, enum, `format: date-time`, `created-at` JSON key, two error types). |
33
+ | `src/codegen/targets/kotlin/__fixtures__/users-golden.kt` | Modify | Regenerate against new fixture and updated stub emitter. |
34
+ | `src/codegen/targets/kotlin/integration.test.ts` | Modify | Update stub emitter map for new fixture; add `UPDATE_GOLDENS=1` regeneration mode. |
35
+ | `src/codegen/targets/kotlin/probe-unsupported-unions.test.ts` | Create | Snapshot test for ajsc's Kotlin `unsupportedUnions: 'fallback'` shape; gated on ajsc resolvability. |
36
+ | `src/codegen/targets/kotlin/e2e-compile.test.ts` | Modify | Drop ajsc-availability gate; keep `kotlinc` + opt-in env gate; point at new fixture; document classpath setup. |
37
+ | `docs/codegen-kotlin.md` | Create | Downstream consumer setup guide (Gradle, contextual serializers, sealed interfaces, `serializer: 'none'`, `unsupportedUnions: 'fallback'` from probe snapshot, documented limitations). |
38
+ | `CLAUDE.md` | Modify | Update Kotlin target bullet: nested-class output shape note, two new flags, pointer to `docs/codegen-kotlin.md`. |
39
+
40
+ ---
41
+
42
+ ## Task 1: Tighten `KotlinEmitOptions` shape
43
+
44
+ **Files:**
45
+ - Modify: `src/codegen/targets/kotlin/ajsc-adapter.ts`
46
+ - Modify: `src/codegen/targets/kotlin/ajsc-adapter.test.ts`
47
+
48
+ Drop dead options (`enumStyle`) and add v7.2's actual surface (`serializer`, `unsupportedUnions`, `inlineTypes`). The interface shape should match what ajsc honors so downstream callers and the production resolver don't pass meaningless fields.
49
+
50
+ - [ ] **Step 1: Update the failing test**
51
+
52
+ Edit `src/codegen/targets/kotlin/ajsc-adapter.test.ts` — extend the existing happy-path test to round-trip the new opts through the stub:
53
+
54
+ ```ts
55
+ import { describe, expect, it } from 'vitest'
56
+ import { createStubKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
57
+
58
+ describe('createStubKotlinEmitter', () => {
59
+ it('returns the configured EmitResult for the matching root name', () => {
60
+ const expected: KotlinEmitResult = {
61
+ code: '@Serializable data class User(val id: String)',
62
+ rootTypeName: 'User',
63
+ extractedTypeNames: [],
64
+ imports: ['kotlinx.serialization.Serializable'],
65
+ }
66
+ const emitter = createStubKotlinEmitter({ User: expected })
67
+ expect(
68
+ emitter.emit(
69
+ { type: 'object' },
70
+ {
71
+ rootTypeName: 'User',
72
+ inlineTypes: true,
73
+ serializer: 'kotlinx',
74
+ unsupportedUnions: 'throw',
75
+ },
76
+ ),
77
+ ).toEqual(expected)
78
+ })
79
+
80
+ it('throws when asked to emit a name not in the stub map', () => {
81
+ const emitter = createStubKotlinEmitter({})
82
+ expect(() => emitter.emit({}, { rootTypeName: 'Missing' })).toThrow(/Missing/)
83
+ })
84
+ })
85
+ ```
86
+
87
+ - [ ] **Step 2: Run the test and verify it fails**
88
+
89
+ ```bash
90
+ npx vitest run src/codegen/targets/kotlin/ajsc-adapter.test.ts
91
+ ```
92
+
93
+ Expected: FAIL — TypeScript complains that `serializer` and `unsupportedUnions` aren't valid fields on `KotlinEmitOptions`.
94
+
95
+ - [ ] **Step 3: Update `KotlinEmitOptions` shape**
96
+
97
+ Edit `src/codegen/targets/kotlin/ajsc-adapter.ts` — replace the existing interface:
98
+
99
+ ```ts
100
+ export interface KotlinEmitOptions {
101
+ rootTypeName: string
102
+ /** Always set true at our call sites; v7.2 default is false. */
103
+ inlineTypes?: boolean
104
+ serializer?: 'kotlinx' | 'none'
105
+ unsupportedUnions?: 'throw' | 'fallback'
106
+ arrayItemNaming?: string | false
107
+ depluralize?: boolean
108
+ uncountableWords?: string[]
109
+ }
110
+ ```
111
+
112
+ Removed: `enumStyle` (TS-only ajsc opt). Kept: passthroughs that ajsc's `BaseConverterOpts` honors.
113
+
114
+ - [ ] **Step 4: Run the test and verify it passes**
115
+
116
+ ```bash
117
+ npx vitest run src/codegen/targets/kotlin/ajsc-adapter.test.ts
118
+ ```
119
+
120
+ Expected: PASS (2 tests).
121
+
122
+ - [ ] **Step 5: Commit**
123
+
124
+ ```bash
125
+ git add src/codegen/targets/kotlin/ajsc-adapter.ts src/codegen/targets/kotlin/ajsc-adapter.test.ts
126
+ git commit -m "refactor(codegen/kotlin): align KotlinEmitOptions with ajsc v7.2 surface"
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Task 2: Pin production resolver error message and drop Phase A TODO
132
+
133
+ **Files:**
134
+ - Modify: `src/codegen/targets/kotlin/ajsc-adapter.ts`
135
+ - Modify: `src/codegen/targets/kotlin/ajsc-adapter.test.ts`
136
+
137
+ ajsc Phase A is delivered (v7.0+); the `TODO(ajsc-phase-a)` marker on `resolveProductionKotlinEmitter` is stale. The function still needs to throw a clear error if `import('ajsc')` fails (e.g., consumer ran `npm install --omit=optional`). Pin that message in a unit test so a future refactor can't silently change it.
138
+
139
+ - [ ] **Step 1: Add a test that pins the error wording**
140
+
141
+ Append to `src/codegen/targets/kotlin/ajsc-adapter.test.ts`:
142
+
143
+ ```ts
144
+ import { resolveProductionKotlinEmitter } from './ajsc-adapter.js'
145
+
146
+ describe('resolveProductionKotlinEmitter', () => {
147
+ it('returns a working emitter when ajsc is installed', async () => {
148
+ const emitter = await resolveProductionKotlinEmitter()
149
+ expect(typeof emitter.emit).toBe('function')
150
+ })
151
+ // Note: testing the failure path (ajsc unavailable) requires module mocking;
152
+ // we leave that as a manual-verification path. The error message is pinned
153
+ // by the message text below so any change requires updating both call sites.
154
+ })
155
+ ```
156
+
157
+ - [ ] **Step 2: Run and verify the new test passes**
158
+
159
+ ```bash
160
+ npx vitest run src/codegen/targets/kotlin/ajsc-adapter.test.ts
161
+ ```
162
+
163
+ Expected: PASS (3 tests). If FAIL because `ajsc` doesn't expose `emitKotlin`, the message text below already covers it — diagnose the install state with `npm ls ajsc`.
164
+
165
+ - [ ] **Step 3: Drop the stale TODO; tighten the error message**
166
+
167
+ In `src/codegen/targets/kotlin/ajsc-adapter.ts`, edit `resolveProductionKotlinEmitter`:
168
+
169
+ ```ts
170
+ /**
171
+ * Resolves the production Kotlin emitter from `ajsc`. Throws a clear error
172
+ * if ajsc is not installed or does not expose `emitKotlin` (e.g. consumer
173
+ * ran `npm install --omit=optional` since ajsc is in optionalDependencies).
174
+ */
175
+ export async function resolveProductionKotlinEmitter(): Promise<KotlinEmitter> {
176
+ const ajsc = await import('ajsc').catch(() => null)
177
+ const emitKotlin = (ajsc as { emitKotlin?: unknown } | null)?.emitKotlin
178
+ if (typeof emitKotlin !== 'function') {
179
+ throw new Error(
180
+ '[ts-procedures-codegen] ajsc.emitKotlin is not available. ' +
181
+ 'Install ajsc (`npm install ajsc`) — it is an optional dependency.',
182
+ )
183
+ }
184
+ return {
185
+ emit(schema, opts) {
186
+ return (emitKotlin as (s: unknown, o: unknown) => KotlinEmitResult)(schema, opts)
187
+ },
188
+ }
189
+ }
190
+ ```
191
+
192
+ - [ ] **Step 4: Run all kotlin adapter tests**
193
+
194
+ ```bash
195
+ npx vitest run src/codegen/targets/kotlin/ajsc-adapter.test.ts
196
+ ```
197
+
198
+ Expected: PASS (3 tests).
199
+
200
+ - [ ] **Step 5: Commit**
201
+
202
+ ```bash
203
+ git add src/codegen/targets/kotlin/ajsc-adapter.ts src/codegen/targets/kotlin/ajsc-adapter.test.ts
204
+ git commit -m "refactor(codegen/kotlin): drop stale phase-a TODO; tighten resolver error wording"
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Task 3: Pass `inlineTypes: true` + new opts per slot in `emit-route-kotlin.ts`
210
+
211
+ **Files:**
212
+ - Modify: `src/codegen/targets/kotlin/emit-route-kotlin.ts`
213
+ - Modify: `src/codegen/targets/kotlin/emit-route-kotlin.test.ts`
214
+
215
+ Make `emitKotlinRoute` a pure function over its inputs. Add an opts argument carrying `serializer` and `unsupportedUnions` (forwarded from pipeline). Hardcode `inlineTypes: true` at every `emitter.emit()` call site. Verify with a spy-style stub emitter that captures the opts it was called with.
216
+
217
+ - [ ] **Step 1: Extend the test stub to capture opts**
218
+
219
+ Edit `src/codegen/targets/kotlin/emit-route-kotlin.test.ts`. Add a helper at the top of the file:
220
+
221
+ ```ts
222
+ import type { KotlinEmitOptions } from './ajsc-adapter.js'
223
+
224
+ interface CapturedCall { schema: unknown; opts: KotlinEmitOptions }
225
+
226
+ function makeSpyEmitter(results: Record<string, KotlinEmitResult>) {
227
+ const calls: CapturedCall[] = []
228
+ const emitter = {
229
+ emit(schema: unknown, opts: KotlinEmitOptions) {
230
+ calls.push({ schema, opts })
231
+ const r = results[opts.rootTypeName]
232
+ if (r == null) throw new Error(`No stubbed result for "${opts.rootTypeName}"`)
233
+ return r
234
+ },
235
+ }
236
+ return { emitter, calls }
237
+ }
238
+ ```
239
+
240
+ Then add a new test asserting opt threading:
241
+
242
+ ```ts
243
+ it('passes inlineTypes:true plus serializer/unsupportedUnions to every slot emit', () => {
244
+ const route = {
245
+ kind: 'api',
246
+ name: 'GetUser',
247
+ method: 'GET',
248
+ fullPath: '/users/:id',
249
+ schema: {
250
+ input: { pathParams: { type: 'object' } },
251
+ returnType: { type: 'object' },
252
+ },
253
+ errors: ['NotFound'],
254
+ } as unknown as AnyHttpRouteDoc
255
+
256
+ const { emitter, calls } = makeSpyEmitter({
257
+ PathParams: ok('@Serializable data class PathParams(val id: String)', 'PathParams'),
258
+ Response: ok('@Serializable data class Response(val id: String)', 'Response'),
259
+ NotFound: ok('@Serializable data class NotFound(val message: String)', 'NotFound'),
260
+ })
261
+
262
+ emitKotlinRoute(route, emitter, new Map([['NotFound', { type: 'object' }]]), {
263
+ serializer: 'none',
264
+ unsupportedUnions: 'fallback',
265
+ })
266
+
267
+ // 3 emits: PathParams, Response, NotFound
268
+ expect(calls.length).toBe(3)
269
+ for (const c of calls) {
270
+ expect(c.opts.inlineTypes).toBe(true)
271
+ expect(c.opts.serializer).toBe('none')
272
+ expect(c.opts.unsupportedUnions).toBe('fallback')
273
+ }
274
+ })
275
+
276
+ it('preserves slot order: pathParams → query → body → response → errors', () => {
277
+ const route = {
278
+ kind: 'api',
279
+ name: 'X',
280
+ method: 'POST',
281
+ fullPath: '/x/:id',
282
+ schema: {
283
+ input: {
284
+ pathParams: { type: 'object' },
285
+ query: { type: 'object' },
286
+ body: { type: 'object' },
287
+ },
288
+ returnType: { type: 'object' },
289
+ },
290
+ errors: ['Z'],
291
+ } as unknown as AnyHttpRouteDoc
292
+
293
+ const { emitter, calls } = makeSpyEmitter({
294
+ PathParams: ok('a', 'PathParams'),
295
+ Query: ok('b', 'Query'),
296
+ Body: ok('c', 'Body'),
297
+ Response: ok('d', 'Response'),
298
+ Z: ok('e', 'Z'),
299
+ })
300
+
301
+ emitKotlinRoute(route, emitter, new Map([['Z', { type: 'object' }]]), {})
302
+
303
+ expect(calls.map((c) => c.opts.rootTypeName)).toEqual([
304
+ 'PathParams', 'Query', 'Body', 'Response', 'Z',
305
+ ])
306
+ })
307
+ ```
308
+
309
+ - [ ] **Step 2: Run the tests and verify they fail**
310
+
311
+ ```bash
312
+ npx vitest run src/codegen/targets/kotlin/emit-route-kotlin.test.ts
313
+ ```
314
+
315
+ Expected: FAIL — `emitKotlinRoute` doesn't accept a fourth argument; `inlineTypes`/`serializer`/`unsupportedUnions` aren't passed.
316
+
317
+ - [ ] **Step 3: Update `emitKotlinRoute` signature and behavior**
318
+
319
+ Edit `src/codegen/targets/kotlin/emit-route-kotlin.ts`:
320
+
321
+ ```ts
322
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
323
+ import type { KotlinEmitter, KotlinEmitOptions } from './ajsc-adapter.js'
324
+ import { indent } from './format-kotlin.js'
325
+
326
+ export interface EmitRouteResult {
327
+ code: string
328
+ imports: string[]
329
+ routeName: string
330
+ /** True when the route was a stream (out-of-scope). Caller logs once. */
331
+ skipped?: boolean
332
+ }
333
+
334
+ /** Subset of KotlinEmitOptions threaded by the pipeline; per-call rootTypeName is set inside. */
335
+ export interface EmitRouteOpts {
336
+ serializer?: 'kotlinx' | 'none'
337
+ unsupportedUnions?: 'throw' | 'fallback'
338
+ arrayItemNaming?: string | false
339
+ depluralize?: boolean
340
+ uncountableWords?: string[]
341
+ }
342
+
343
+ const COLON_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g
344
+
345
+ function toBracePath(template: string): string {
346
+ return template.replace(COLON_PARAM_RE, '{$1}')
347
+ }
348
+
349
+ function pathParamNames(template: string): string[] {
350
+ const names: string[] = []
351
+ for (const match of template.matchAll(COLON_PARAM_RE)) names.push(match[1]!)
352
+ return names
353
+ }
354
+
355
+ function buildPathFn(bracePath: string, params: string[]): string {
356
+ if (params.length === 0) return `const val path = "${bracePath}"`
357
+ let body = bracePath
358
+ for (const name of params) body = body.replace(`{${name}}`, `\${p.${name}}`)
359
+ return `fun path(p: PathParams): String = "${body}"`
360
+ }
361
+
362
+ function emitOptsFor(rootTypeName: string, routeOpts: EmitRouteOpts): KotlinEmitOptions {
363
+ return {
364
+ rootTypeName,
365
+ inlineTypes: true,
366
+ ...(routeOpts.serializer !== undefined ? { serializer: routeOpts.serializer } : {}),
367
+ ...(routeOpts.unsupportedUnions !== undefined ? { unsupportedUnions: routeOpts.unsupportedUnions } : {}),
368
+ ...(routeOpts.arrayItemNaming !== undefined ? { arrayItemNaming: routeOpts.arrayItemNaming } : {}),
369
+ ...(routeOpts.depluralize !== undefined ? { depluralize: routeOpts.depluralize } : {}),
370
+ ...(routeOpts.uncountableWords !== undefined ? { uncountableWords: routeOpts.uncountableWords } : {}),
371
+ }
372
+ }
373
+
374
+ export function emitKotlinRoute(
375
+ route: AnyHttpRouteDoc,
376
+ emitter: KotlinEmitter,
377
+ errorSchemas: Map<string, unknown>,
378
+ routeOpts: EmitRouteOpts = {},
379
+ ): EmitRouteResult {
380
+ const kind = (route as { kind?: string }).kind
381
+ if (kind === 'stream') {
382
+ return { code: '', imports: [], routeName: route.name, skipped: true }
383
+ }
384
+
385
+ const isApi = kind === 'api' || 'fullPath' in route
386
+ const rawPath = isApi
387
+ ? (route as { fullPath: string }).fullPath
388
+ : (route as { path: string }).path
389
+ const method = String((route as { method: string }).method).toUpperCase()
390
+ const bracePath = toBracePath(rawPath)
391
+ const params = pathParamNames(rawPath)
392
+
393
+ const lines: string[] = [
394
+ `const val method = "${method}"`,
395
+ `const val pathTemplate = "${bracePath}"`,
396
+ buildPathFn(bracePath, params),
397
+ ]
398
+ const imports: string[] = []
399
+
400
+ const schema = (route as { schema?: Record<string, unknown> }).schema ?? {}
401
+ const input = (schema.input ?? {}) as Record<string, unknown>
402
+
403
+ const slots: Array<{ rootName: string; source: unknown }> = [
404
+ { rootName: 'PathParams', source: input.pathParams },
405
+ { rootName: 'Query', source: input.query },
406
+ { rootName: 'Body', source: input.body },
407
+ { rootName: 'Response', source: schema.returnType },
408
+ ]
409
+
410
+ for (const slot of slots) {
411
+ if (slot.source == null) continue
412
+ const result = emitter.emit(slot.source as Record<string, unknown>, emitOptsFor(slot.rootName, routeOpts))
413
+ lines.push('')
414
+ lines.push(result.code)
415
+ imports.push(...result.imports)
416
+ }
417
+
418
+ const routeErrorKeys = ((route as { errors?: string[] }).errors ?? [])
419
+ .filter((key) => errorSchemas.has(key))
420
+ if (routeErrorKeys.length > 0) {
421
+ const inner: string[] = []
422
+ for (const key of routeErrorKeys) {
423
+ const r = emitter.emit(errorSchemas.get(key) as Record<string, unknown>, emitOptsFor(key, routeOpts))
424
+ inner.push(r.code)
425
+ imports.push(...r.imports)
426
+ }
427
+ lines.push('')
428
+ lines.push('object Errors {')
429
+ lines.push(indent(inner.join('\n\n'), 1))
430
+ lines.push('}')
431
+ }
432
+
433
+ return { code: lines.join('\n'), imports, routeName: route.name, skipped: false }
434
+ }
435
+ ```
436
+
437
+ - [ ] **Step 4: Update existing tests to pass the new (optional) opts arg**
438
+
439
+ The two new tests above pass `routeOpts` explicitly. The existing tests don't pass it (ok — opts default to `{}`). Verify the existing stream-skip test no longer asserts `console.warn`:
440
+
441
+ ```ts
442
+ // In the existing 'skips stream routes with a warning' test, replace:
443
+ // const result = emitKotlinRoute(route, createStubKotlinEmitter({}), noErrors)
444
+ // expect(result.code).toBe('')
445
+ // with:
446
+ const result = emitKotlinRoute(route, createStubKotlinEmitter({}), noErrors)
447
+ expect(result.code).toBe('')
448
+ expect(result.skipped).toBe(true)
449
+ // Rename the test: `returns skipped:true for stream routes` (drop "with a warning")
450
+ ```
451
+
452
+ - [ ] **Step 5: Run all emit-route tests**
453
+
454
+ ```bash
455
+ npx vitest run src/codegen/targets/kotlin/emit-route-kotlin.test.ts
456
+ ```
457
+
458
+ Expected: PASS (7 tests — 5 existing + 2 new).
459
+
460
+ - [ ] **Step 6: Commit**
461
+
462
+ ```bash
463
+ git add src/codegen/targets/kotlin/emit-route-kotlin.ts src/codegen/targets/kotlin/emit-route-kotlin.test.ts
464
+ git commit -m "feat(codegen/kotlin): hardcode inlineTypes:true; thread serializer/unsupportedUnions per slot"
465
+ ```
466
+
467
+ ---
468
+
469
+ ## Task 4: Plumb new opts through `emit-scope-kotlin.ts` + collect skipped streams
470
+
471
+ **Files:**
472
+ - Modify: `src/codegen/targets/kotlin/emit-scope-kotlin.ts`
473
+ - Modify: `src/codegen/targets/kotlin/emit-scope-kotlin.test.ts`
474
+
475
+ The scope emitter needs to (a) accept the new opts and pass them to `emitKotlinRoute`, and (b) collect the names of skipped stream routes so the pipeline can summarize once.
476
+
477
+ - [ ] **Step 1: Add tests for opt threading and skipped-stream collection**
478
+
479
+ Edit `src/codegen/targets/kotlin/emit-scope-kotlin.test.ts`. Add tests:
480
+
481
+ ```ts
482
+ it('threads serializer/unsupportedUnions through to every emitter call', () => {
483
+ const route = { kind: 'api', name: 'X', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
484
+ const group: ScopeGroup = { scopeKey: 'x', camelCase: 'x', routes: [route] }
485
+
486
+ const calls: KotlinEmitOptions[] = []
487
+ const emitter = {
488
+ emit(_s: unknown, opts: KotlinEmitOptions) {
489
+ calls.push(opts)
490
+ return { code: 'data class Response', rootTypeName: 'Response', extractedTypeNames: [], imports: [] }
491
+ },
492
+ }
493
+
494
+ emitKotlinScope(
495
+ group,
496
+ { kotlinPackage: 'p', sourceHash: 'h', serializer: 'none', unsupportedUnions: 'fallback' },
497
+ emitter,
498
+ new Map(),
499
+ )
500
+
501
+ expect(calls.length).toBe(1)
502
+ expect(calls[0]!.serializer).toBe('none')
503
+ expect(calls[0]!.unsupportedUnions).toBe('fallback')
504
+ expect(calls[0]!.inlineTypes).toBe(true)
505
+ })
506
+
507
+ it('collects skipped stream-route names', () => {
508
+ const stream = { kind: 'stream', name: 'WatchUsers', method: 'GET', path: '/u/stream', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
509
+ const api = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
510
+ const group: ScopeGroup = { scopeKey: 'u', camelCase: 'u', routes: [stream, api] }
511
+
512
+ const emitter = createStubKotlinEmitter({
513
+ Response: ok('data class Response(val id: String)', 'Response'),
514
+ })
515
+ const result = emitKotlinScope(group, { kotlinPackage: 'p', sourceHash: 'h' }, emitter, new Map())
516
+
517
+ expect(result.skippedStreams).toEqual(['WatchUsers'])
518
+ expect(result.code).toContain('object GetUser')
519
+ expect(result.code).not.toContain('object WatchUsers')
520
+ })
521
+ ```
522
+
523
+ (Add `import type { KotlinEmitOptions } from './ajsc-adapter.js'` at the top of the test file.)
524
+
525
+ - [ ] **Step 2: Run the tests and verify they fail**
526
+
527
+ ```bash
528
+ npx vitest run src/codegen/targets/kotlin/emit-scope-kotlin.test.ts
529
+ ```
530
+
531
+ Expected: FAIL — `serializer`/`unsupportedUnions` not on `EmitScopeOptions`; `skippedStreams` not on `EmittedKotlinFile`.
532
+
533
+ - [ ] **Step 3: Update the scope emitter**
534
+
535
+ Edit `src/codegen/targets/kotlin/emit-scope-kotlin.ts`:
536
+
537
+ ```ts
538
+ import type { ScopeGroup } from '../../group-routes.js'
539
+ import type { KotlinEmitter } from './ajsc-adapter.js'
540
+ import { emitKotlinRoute, type EmitRouteOpts } from './emit-route-kotlin.js'
541
+ import { kotlinPackageDecl, kotlinSourceHashHeader, kotlinImports, indent } from './format-kotlin.js'
542
+
543
+ export interface EmitScopeOptions extends EmitRouteOpts {
544
+ kotlinPackage: string
545
+ sourceHash: string
546
+ }
547
+
548
+ export interface EmittedKotlinFile {
549
+ filename: string
550
+ code: string
551
+ /** Names of stream routes within this scope that were skipped. */
552
+ skippedStreams: string[]
553
+ }
554
+
555
+ function pascalCase(scope: string): string {
556
+ return scope
557
+ .split('-')
558
+ .filter((p) => p.length > 0)
559
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
560
+ .join('')
561
+ }
562
+
563
+ export function emitKotlinScope(
564
+ group: ScopeGroup,
565
+ opts: EmitScopeOptions,
566
+ emitter: KotlinEmitter,
567
+ errorSchemas: Map<string, unknown>,
568
+ ): EmittedKotlinFile {
569
+ const scopeName = pascalCase(group.scopeKey)
570
+ const allImports: string[] = []
571
+ const routeBlocks: string[] = []
572
+ const skippedStreams: string[] = []
573
+
574
+ const routeOpts: EmitRouteOpts = {
575
+ ...(opts.serializer !== undefined ? { serializer: opts.serializer } : {}),
576
+ ...(opts.unsupportedUnions !== undefined ? { unsupportedUnions: opts.unsupportedUnions } : {}),
577
+ ...(opts.arrayItemNaming !== undefined ? { arrayItemNaming: opts.arrayItemNaming } : {}),
578
+ ...(opts.depluralize !== undefined ? { depluralize: opts.depluralize } : {}),
579
+ ...(opts.uncountableWords !== undefined ? { uncountableWords: opts.uncountableWords } : {}),
580
+ }
581
+
582
+ for (const route of group.routes) {
583
+ const r = emitKotlinRoute(route, emitter, errorSchemas, routeOpts)
584
+ if (r.skipped) {
585
+ skippedStreams.push(r.routeName)
586
+ continue
587
+ }
588
+ if (r.code === '') continue
589
+ allImports.push(...r.imports)
590
+ const wrapped = `object ${r.routeName} {\n${indent(r.code, 1)}\n}`
591
+ routeBlocks.push(wrapped)
592
+ }
593
+
594
+ const innerScope = routeBlocks.length === 0 ? '' : indent(routeBlocks.join('\n\n'), 1)
595
+ const scopeBlock = innerScope === ''
596
+ ? `object ${scopeName} {\n}`
597
+ : `object ${scopeName} {\n${innerScope}\n}`
598
+
599
+ const importsBlock = kotlinImports(allImports)
600
+ const parts = [
601
+ kotlinPackageDecl(opts.kotlinPackage),
602
+ kotlinSourceHashHeader(opts.sourceHash),
603
+ importsBlock,
604
+ scopeBlock,
605
+ ].filter((p) => p.length > 0)
606
+
607
+ return { filename: `${scopeName}.kt`, code: parts.join('\n\n') + '\n', skippedStreams }
608
+ }
609
+ ```
610
+
611
+ - [ ] **Step 4: Run scope-emitter tests**
612
+
613
+ ```bash
614
+ npx vitest run src/codegen/targets/kotlin/emit-scope-kotlin.test.ts
615
+ ```
616
+
617
+ Expected: PASS.
618
+
619
+ - [ ] **Step 5: Commit**
620
+
621
+ ```bash
622
+ git add src/codegen/targets/kotlin/emit-scope-kotlin.ts src/codegen/targets/kotlin/emit-scope-kotlin.test.ts
623
+ git commit -m "feat(codegen/kotlin): scope emitter threads new opts and surfaces skippedStreams"
624
+ ```
625
+
626
+ ---
627
+
628
+ ## Task 5: Pipeline plumbing for new opts + skipped-stream summary
629
+
630
+ **Files:**
631
+ - Modify: `src/codegen/pipeline.ts`
632
+ - Modify: `src/codegen/index.ts`
633
+ - Modify: `src/codegen/pipeline.test.ts`
634
+
635
+ Add `kotlinSerializer` and `unsupportedUnions` to `PipelineOptions` and `GenerateClientOptions`. Aggregate `skippedStreams` across all scopes and emit one console.log summary line.
636
+
637
+ - [ ] **Step 1: Add tests for opt threading and summary log**
638
+
639
+ Append to `src/codegen/pipeline.test.ts` inside the existing `describe('runPipeline (kotlin target)', ...)`:
640
+
641
+ ```ts
642
+ it('threads kotlinSerializer and unsupportedUnions to the emitter', async () => {
643
+ const calls: KotlinEmitOptions[] = []
644
+ const captureEmitter = {
645
+ emit(_s: unknown, opts: KotlinEmitOptions) {
646
+ calls.push(opts)
647
+ return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
648
+ },
649
+ }
650
+
651
+ const envelope = {
652
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
653
+ routes: [
654
+ {
655
+ kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/users/:id',
656
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
657
+ errors: [],
658
+ },
659
+ ],
660
+ } as any
661
+
662
+ await runPipeline({
663
+ envelope, outDir: 'out', dryRun: true,
664
+ target: 'kotlin', kotlinPackage: 'p',
665
+ kotlinSerializer: 'none',
666
+ unsupportedUnions: 'fallback',
667
+ kotlinEmitter: captureEmitter,
668
+ })
669
+
670
+ expect(calls.length).toBeGreaterThan(0)
671
+ for (const c of calls) {
672
+ expect(c.serializer).toBe('none')
673
+ expect(c.unsupportedUnions).toBe('fallback')
674
+ expect(c.inlineTypes).toBe(true)
675
+ }
676
+ })
677
+
678
+ it('logs a single summary line for skipped stream routes', async () => {
679
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
680
+ try {
681
+ const envelope = {
682
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
683
+ routes: [
684
+ { kind: 'stream', name: 'WatchA', scope: 's', method: 'GET', path: '/a', schema: {}, errors: [] },
685
+ { kind: 'stream', name: 'WatchB', scope: 's', method: 'GET', path: '/b', schema: {}, errors: [] },
686
+ { kind: 'api', name: 'GetThing', scope: 's', method: 'GET', fullPath: '/c', schema: { returnType: { type: 'object' } }, errors: [] },
687
+ ],
688
+ } as any
689
+ await runPipeline({
690
+ envelope, outDir: 'out', dryRun: true,
691
+ target: 'kotlin', kotlinPackage: 'p',
692
+ kotlinEmitter: createStubKotlinEmitter({
693
+ Response: { code: 'data class Response(val ok: Boolean)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] },
694
+ }),
695
+ })
696
+ const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
697
+ expect(summary).toBeDefined()
698
+ expect(String(summary![0])).toContain('Skipped 2 stream routes')
699
+ expect(String(summary![0])).toContain('WatchA')
700
+ expect(String(summary![0])).toContain('WatchB')
701
+ } finally {
702
+ logSpy.mockRestore()
703
+ }
704
+ })
705
+
706
+ it('does not log a summary when there are no skipped streams', async () => {
707
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
708
+ try {
709
+ const envelope = {
710
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
711
+ routes: [{ kind: 'api', name: 'X', scope: 's', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] }],
712
+ } as any
713
+ await runPipeline({
714
+ envelope, outDir: 'out', dryRun: true,
715
+ target: 'kotlin', kotlinPackage: 'p',
716
+ kotlinEmitter: createStubKotlinEmitter({ Response: { code: 'data class Response(val x: Int)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] } }),
717
+ })
718
+ const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
719
+ expect(summary).toBeUndefined()
720
+ } finally {
721
+ logSpy.mockRestore()
722
+ }
723
+ })
724
+ ```
725
+
726
+ (Add `import type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'` near the existing imports.)
727
+
728
+ - [ ] **Step 2: Run tests and verify they fail**
729
+
730
+ ```bash
731
+ npx vitest run src/codegen/pipeline.test.ts
732
+ ```
733
+
734
+ Expected: FAIL — `kotlinSerializer` and `unsupportedUnions` aren't on `PipelineOptions`; no summary log.
735
+
736
+ - [ ] **Step 3: Update `PipelineOptions` and runtime behavior**
737
+
738
+ Edit `src/codegen/pipeline.ts`. Add fields:
739
+
740
+ ```ts
741
+ export interface PipelineOptions {
742
+ // ... existing fields ...
743
+ target?: 'ts' | 'kotlin'
744
+ kotlinPackage?: string
745
+ kotlinSerializer?: 'kotlinx' | 'none'
746
+ unsupportedUnions?: 'throw' | 'fallback'
747
+ kotlinEmitter?: KotlinEmitter
748
+ }
749
+ ```
750
+
751
+ Inside `runPipeline`'s `target === 'kotlin'` branch, build the route-opts (forwarding both Kotlin-specific opts and the relevant pass-throughs from `options.ajsc`) and aggregate skipped streams:
752
+
753
+ ```ts
754
+ if (options.target === 'kotlin') {
755
+ if (options.kotlinPackage == null) {
756
+ throw new Error('[ts-procedures-codegen] target=kotlin requires kotlinPackage')
757
+ }
758
+ if (options.kotlinEmitter == null) {
759
+ throw new Error(
760
+ '[ts-procedures-codegen] target=kotlin requires a kotlinEmitter (CLI resolves via resolveProductionKotlinEmitter)'
761
+ )
762
+ }
763
+
764
+ const errorSchemas = new Map<string, unknown>()
765
+ for (const e of envelope.errors) {
766
+ if (e.schema != null) errorSchemas.set(e.name, e.schema)
767
+ }
768
+
769
+ // Spec §"CLI flags" — `--array-item-naming`, `--depluralize`, `--uncountable-words`
770
+ // also apply to the Kotlin target. The CLI parks them on `options.ajsc` (a TS-target
771
+ // structure historically); copy the Kotlin-relevant subset onto the scope opts here.
772
+ const ajscPassthrough = options.ajsc ?? {}
773
+
774
+ const kotlinFiles: GeneratedFile[] = []
775
+ const allSkipped: string[] = []
776
+ for (const group of groupArray) {
777
+ const emitted = emitKotlinScope(
778
+ group,
779
+ {
780
+ kotlinPackage: options.kotlinPackage,
781
+ sourceHash: hash,
782
+ ...(options.kotlinSerializer !== undefined ? { serializer: options.kotlinSerializer } : {}),
783
+ ...(options.unsupportedUnions !== undefined ? { unsupportedUnions: options.unsupportedUnions } : {}),
784
+ ...(ajscPassthrough.arrayItemNaming !== undefined ? { arrayItemNaming: ajscPassthrough.arrayItemNaming } : {}),
785
+ ...(ajscPassthrough.depluralize !== undefined ? { depluralize: ajscPassthrough.depluralize } : {}),
786
+ ...(ajscPassthrough.uncountableWords !== undefined ? { uncountableWords: ajscPassthrough.uncountableWords } : {}),
787
+ },
788
+ options.kotlinEmitter,
789
+ errorSchemas,
790
+ )
791
+ kotlinFiles.push({ path: join(outDir, emitted.filename), code: emitted.code })
792
+ allSkipped.push(...emitted.skippedStreams)
793
+ }
794
+
795
+ if (allSkipped.length > 0) {
796
+ console.log(
797
+ `[ts-procedures-codegen] Skipped ${allSkipped.length} stream route${allSkipped.length === 1 ? '' : 's'} (kotlin target): ${allSkipped.join(', ')}`,
798
+ )
799
+ }
800
+
801
+ // ... existing dryRun / write logic unchanged ...
802
+ return kotlinFiles
803
+ }
804
+ ```
805
+
806
+ Also add a focused test asserting the passthrough wiring works. Append to `pipeline.test.ts` inside the existing `describe('runPipeline (kotlin target)', ...)`:
807
+
808
+ ```ts
809
+ it('forwards ajsc passthroughs (arrayItemNaming/depluralize/uncountableWords) to the kotlin emitter', async () => {
810
+ const calls: KotlinEmitOptions[] = []
811
+ const captureEmitter = {
812
+ emit(_s: unknown, opts: KotlinEmitOptions) {
813
+ calls.push(opts)
814
+ return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
815
+ },
816
+ }
817
+
818
+ const envelope = {
819
+ basePath: '/api', headers: [], version: '1' as const, errors: [],
820
+ routes: [{ kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] }],
821
+ } as any
822
+
823
+ await runPipeline({
824
+ envelope, outDir: 'out', dryRun: true,
825
+ target: 'kotlin', kotlinPackage: 'p',
826
+ ajsc: { arrayItemNaming: 'Item', depluralize: true, uncountableWords: ['data'] },
827
+ kotlinEmitter: captureEmitter,
828
+ })
829
+
830
+ expect(calls.length).toBe(1)
831
+ expect(calls[0]!.arrayItemNaming).toBe('Item')
832
+ expect(calls[0]!.depluralize).toBe(true)
833
+ expect(calls[0]!.uncountableWords).toEqual(['data'])
834
+ })
835
+ ```
836
+
837
+ - [ ] **Step 4: Update `index.ts` to surface and pass the new opts**
838
+
839
+ Edit `src/codegen/index.ts`:
840
+
841
+ ```ts
842
+ export interface GenerateClientOptions extends ResolveInput {
843
+ // ... existing fields ...
844
+ target?: 'ts' | 'kotlin'
845
+ kotlinPackage?: string
846
+ kotlinSerializer?: 'kotlinx' | 'none'
847
+ unsupportedUnions?: 'throw' | 'fallback'
848
+ kotlinEmitter?: KotlinEmitter
849
+ }
850
+
851
+ export async function generateClient(options: GenerateClientOptions): Promise<GeneratedFile[]> {
852
+ const envelope = await resolveEnvelope(options)
853
+ return runPipeline({
854
+ envelope,
855
+ outDir: options.outDir,
856
+ ajsc: options.ajsc,
857
+ clientImportPath: options.clientImportPath,
858
+ dryRun: options.dryRun,
859
+ namespaceTypes: options.namespaceTypes,
860
+ selfContained: options.selfContained,
861
+ serviceName: options.serviceName,
862
+ cleanOutDir: options.cleanOutDir,
863
+ target: options.target,
864
+ kotlinPackage: options.kotlinPackage,
865
+ kotlinSerializer: options.kotlinSerializer,
866
+ unsupportedUnions: options.unsupportedUnions,
867
+ kotlinEmitter: options.kotlinEmitter,
868
+ })
869
+ }
870
+ ```
871
+
872
+ - [ ] **Step 5: Run all pipeline tests**
873
+
874
+ ```bash
875
+ npx vitest run src/codegen/pipeline.test.ts
876
+ ```
877
+
878
+ Expected: PASS (all existing + 3 new).
879
+
880
+ - [ ] **Step 6: Run the full codegen suite to verify TS path is unaffected**
881
+
882
+ ```bash
883
+ npx vitest run src/codegen/
884
+ ```
885
+
886
+ Expected: PASS.
887
+
888
+ - [ ] **Step 7: Commit**
889
+
890
+ ```bash
891
+ git add src/codegen/pipeline.ts src/codegen/pipeline.test.ts src/codegen/index.ts
892
+ git commit -m "feat(codegen/kotlin): pipeline threads serializer/unsupportedUnions and summarizes skipped streams"
893
+ ```
894
+
895
+ ---
896
+
897
+ ## Task 6: CLI flags `--kotlin-serializer` and `--unsupported-unions`
898
+
899
+ **Files:**
900
+ - Modify: `src/codegen/bin/cli.ts`
901
+ - Modify: `src/codegen/bin/cli.test.ts`
902
+
903
+ Add two new flags. Per spec: `--kotlin-serializer` lands at `parsed.kotlin.serializer`; `--unsupported-unions` lands at top-level `parsed.unsupportedUnions`. Defaults match prior behavior (`kotlinx`, `throw`).
904
+
905
+ - [ ] **Step 1: Add tests for both flags**
906
+
907
+ Append to `src/codegen/bin/cli.test.ts`:
908
+
909
+ ```ts
910
+ describe('cli — kotlin-serializer flag', () => {
911
+ it('parses --kotlin-serializer kotlinx', () => {
912
+ const args = parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'kotlinx', '--out', 'o', '--file', 'e.json'])
913
+ expect(args.kotlin?.serializer).toBe('kotlinx')
914
+ })
915
+
916
+ it('parses --kotlin-serializer none', () => {
917
+ const args = parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'none', '--out', 'o', '--file', 'e.json'])
918
+ expect(args.kotlin?.serializer).toBe('none')
919
+ })
920
+
921
+ it('reads kotlin.serializer from config', () => {
922
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'], {
923
+ target: 'kotlin', kotlin: { package: 'p', serializer: 'none' }, outDir: 'o', file: 'e.json',
924
+ } as CodegenConfig)
925
+ expect(args.kotlin?.serializer).toBe('none')
926
+ })
927
+
928
+ it('CLI overrides config', () => {
929
+ const args = parseArgs(['--kotlin-serializer', 'kotlinx', '--out', 'o', '--file', 'e.json'], {
930
+ target: 'kotlin', kotlin: { package: 'p', serializer: 'none' }, outDir: 'o', file: 'e.json',
931
+ } as CodegenConfig)
932
+ expect(args.kotlin?.serializer).toBe('kotlinx')
933
+ })
934
+
935
+ it('throws on invalid value', () => {
936
+ expect(() => parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'bogus', '--out', 'o', '--file', 'e.json']))
937
+ .toThrow(/--kotlin-serializer/)
938
+ })
939
+ })
940
+
941
+ describe('cli — unsupported-unions flag', () => {
942
+ it('parses --unsupported-unions throw', () => {
943
+ const args = parseArgs(['--unsupported-unions', 'throw', '--out', 'o', '--file', 'e.json'])
944
+ expect(args.unsupportedUnions).toBe('throw')
945
+ })
946
+
947
+ it('parses --unsupported-unions fallback', () => {
948
+ const args = parseArgs(['--unsupported-unions', 'fallback', '--out', 'o', '--file', 'e.json'])
949
+ expect(args.unsupportedUnions).toBe('fallback')
950
+ })
951
+
952
+ it('reads unsupportedUnions from config', () => {
953
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'], {
954
+ unsupportedUnions: 'fallback', outDir: 'o', file: 'e.json',
955
+ } as CodegenConfig)
956
+ expect(args.unsupportedUnions).toBe('fallback')
957
+ })
958
+
959
+ it('CLI overrides config', () => {
960
+ const args = parseArgs(['--unsupported-unions', 'throw', '--out', 'o', '--file', 'e.json'], {
961
+ unsupportedUnions: 'fallback', outDir: 'o', file: 'e.json',
962
+ } as CodegenConfig)
963
+ expect(args.unsupportedUnions).toBe('throw')
964
+ })
965
+
966
+ it('throws on invalid value', () => {
967
+ expect(() => parseArgs(['--unsupported-unions', 'bogus', '--out', 'o', '--file', 'e.json']))
968
+ .toThrow(/--unsupported-unions/)
969
+ })
970
+
971
+ it('default is undefined when not set', () => {
972
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'])
973
+ expect(args.unsupportedUnions).toBeUndefined()
974
+ })
975
+ })
976
+ ```
977
+
978
+ - [ ] **Step 2: Run tests and verify they fail**
979
+
980
+ ```bash
981
+ npx vitest run src/codegen/bin/cli.test.ts
982
+ ```
983
+
984
+ Expected: FAIL — flags not parsed; types missing.
985
+
986
+ - [ ] **Step 3: Update `CodegenConfig`, `ParsedArgs`, and flag parsing**
987
+
988
+ Edit `src/codegen/bin/cli.ts`:
989
+
990
+ ```ts
991
+ export interface CodegenConfig {
992
+ // ... existing ...
993
+ target?: 'ts' | 'kotlin'
994
+ kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
995
+ unsupportedUnions?: 'throw' | 'fallback'
996
+ }
997
+
998
+ export interface ParsedArgs {
999
+ // ... existing ...
1000
+ target?: 'ts' | 'kotlin'
1001
+ kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
1002
+ unsupportedUnions?: 'throw' | 'fallback'
1003
+ }
1004
+ ```
1005
+
1006
+ In `parseArgs`, alongside the existing `kotlinPackage` local, add:
1007
+
1008
+ ```ts
1009
+ let kotlinSerializer: 'kotlinx' | 'none' | undefined = config?.kotlin?.serializer
1010
+ let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
1011
+ ```
1012
+
1013
+ Add cases in the argv loop (placed near the existing `--kotlin-package` case):
1014
+
1015
+ ```ts
1016
+ } else if (arg === '--kotlin-serializer') {
1017
+ const val = argv[++i]
1018
+ if (val === 'kotlinx' || val === 'none') {
1019
+ kotlinSerializer = val
1020
+ } else {
1021
+ throw new Error(`Invalid --kotlin-serializer value: ${val ?? '(missing)'} (expected 'kotlinx' or 'none')`)
1022
+ }
1023
+ } else if (arg === '--unsupported-unions') {
1024
+ const val = argv[++i]
1025
+ if (val === 'throw' || val === 'fallback') {
1026
+ unsupportedUnions = val
1027
+ } else {
1028
+ throw new Error(`Invalid --unsupported-unions value: ${val ?? '(missing)'} (expected 'throw' or 'fallback')`)
1029
+ }
1030
+ }
1031
+ ```
1032
+
1033
+ In the return object, replace the existing kotlin spread with one that includes serializer when set, and add `unsupportedUnions`:
1034
+
1035
+ ```ts
1036
+ return {
1037
+ // ... existing ...
1038
+ ...(target !== undefined ? { target } : {}),
1039
+ ...(kotlinPackage !== undefined
1040
+ ? {
1041
+ kotlin: {
1042
+ package: kotlinPackage,
1043
+ ...(kotlinSerializer !== undefined ? { serializer: kotlinSerializer } : {}),
1044
+ },
1045
+ }
1046
+ : {}),
1047
+ ...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
1048
+ }
1049
+ ```
1050
+
1051
+ In `runWithWatch` and `main`, when forwarding to `generateClient`, include the new fields:
1052
+
1053
+ ```ts
1054
+ const result = await generateClient({
1055
+ // ... existing ...
1056
+ ...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
1057
+ ...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
1058
+ ...kotlinWiring,
1059
+ })
1060
+ ```
1061
+
1062
+ (Same change in the `runWithWatch` `runPipeline` call.)
1063
+
1064
+ - [ ] **Step 4: Run cli tests**
1065
+
1066
+ ```bash
1067
+ npx vitest run src/codegen/bin/cli.test.ts
1068
+ ```
1069
+
1070
+ Expected: PASS.
1071
+
1072
+ - [ ] **Step 5: Commit**
1073
+
1074
+ ```bash
1075
+ git add src/codegen/bin/cli.ts src/codegen/bin/cli.test.ts
1076
+ git commit -m "feat(codegen/cli): add --kotlin-serializer and --unsupported-unions flags"
1077
+ ```
1078
+
1079
+ ---
1080
+
1081
+ ## Task 7: CLI prints setup-guide pointer after a successful Kotlin run
1082
+
1083
+ **Files:**
1084
+ - Modify: `src/codegen/bin/cli.ts`
1085
+ - Modify: `src/codegen/bin/cli.test.ts`
1086
+
1087
+ Extract a small testable helper that prints a one-line pointer to `docs/codegen-kotlin.md`. Call it from `main()` after a successful (non-watch, non-dry-run) Kotlin generation.
1088
+
1089
+ - [ ] **Step 1: Add a test for the helper**
1090
+
1091
+ Append to `src/codegen/bin/cli.test.ts`:
1092
+
1093
+ ```ts
1094
+ import { printPostRunHints } from './cli.js'
1095
+
1096
+ describe('cli — printPostRunHints', () => {
1097
+ it('prints a setup-guide pointer for the kotlin target', () => {
1098
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
1099
+ try {
1100
+ printPostRunHints({ target: 'kotlin' })
1101
+ const matched = logSpy.mock.calls.some((c) => String(c[0]).includes('docs/codegen-kotlin.md'))
1102
+ expect(matched).toBe(true)
1103
+ } finally {
1104
+ logSpy.mockRestore()
1105
+ }
1106
+ })
1107
+
1108
+ it('prints nothing for the ts target', () => {
1109
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
1110
+ try {
1111
+ printPostRunHints({ target: 'ts' })
1112
+ expect(logSpy).not.toHaveBeenCalled()
1113
+ } finally {
1114
+ logSpy.mockRestore()
1115
+ }
1116
+ })
1117
+
1118
+ it('prints nothing when target is undefined (default ts)', () => {
1119
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
1120
+ try {
1121
+ printPostRunHints({})
1122
+ expect(logSpy).not.toHaveBeenCalled()
1123
+ } finally {
1124
+ logSpy.mockRestore()
1125
+ }
1126
+ })
1127
+ })
1128
+ ```
1129
+
1130
+ (Add `import { vi } from 'vitest'` to the existing imports if not already present.)
1131
+
1132
+ - [ ] **Step 2: Run the test and verify it fails**
1133
+
1134
+ ```bash
1135
+ npx vitest run src/codegen/bin/cli.test.ts
1136
+ ```
1137
+
1138
+ Expected: FAIL — `printPostRunHints` not exported.
1139
+
1140
+ - [ ] **Step 3: Add the helper and call it from `main`**
1141
+
1142
+ In `src/codegen/bin/cli.ts`, add (near the bottom of the file, before `main`):
1143
+
1144
+ ```ts
1145
+ const KOTLIN_SETUP_GUIDE_URL =
1146
+ 'https://bitbucket.org/thermsio/ts-procedures/src/master/docs/codegen-kotlin.md'
1147
+
1148
+ export function printPostRunHints(parsed: { target?: 'ts' | 'kotlin' }): void {
1149
+ if (parsed.target === 'kotlin') {
1150
+ console.log(`[ts-procedures-codegen] Kotlin setup guide: ${KOTLIN_SETUP_GUIDE_URL}`)
1151
+ }
1152
+ }
1153
+ ```
1154
+
1155
+ Then in `main()`, after the successful (non-dry-run) generation, call it:
1156
+
1157
+ ```ts
1158
+ if (parsed.dryRun) {
1159
+ console.log(`[ts-procedures-codegen] Dry run complete — ${result.length} files would be generated`)
1160
+ } else {
1161
+ console.log(`[ts-procedures-codegen] Generated ${result.length} files → ${parsed.outDir}`)
1162
+ printPostRunHints(parsed)
1163
+ }
1164
+ ```
1165
+
1166
+ (Also call `printPostRunHints(parsed)` once after `runWithWatch`'s first successful `run()` if you want the watch path to show it — defer to implementer judgement; the spec only requires the non-watch path.)
1167
+
1168
+ - [ ] **Step 4: Run cli tests**
1169
+
1170
+ ```bash
1171
+ npx vitest run src/codegen/bin/cli.test.ts
1172
+ ```
1173
+
1174
+ Expected: PASS (3 new tests).
1175
+
1176
+ - [ ] **Step 5: Commit**
1177
+
1178
+ ```bash
1179
+ git add src/codegen/bin/cli.ts src/codegen/bin/cli.test.ts
1180
+ git commit -m "feat(codegen/cli): print kotlin setup-guide pointer after successful run"
1181
+ ```
1182
+
1183
+ ---
1184
+
1185
+ ## Task 8: Replace fixture envelope with realistic schemas
1186
+
1187
+ **Files:**
1188
+ - Modify: `src/codegen/targets/kotlin/__fixtures__/users-envelope.json`
1189
+
1190
+ Data-only task; no code or test changes here. Subsequent tasks consume this fixture.
1191
+
1192
+ The fixture exercises (per spec):
1193
+ - nested objects (response with embedded `address`),
1194
+ - a discriminated `oneOf` (body of `CreateUser`),
1195
+ - an enum (`status` field on the list response),
1196
+ - `format: date-time` (`createdAt` on the response),
1197
+ - a kebab-case JSON key (`created-at` mapped to `createdAt`),
1198
+ - two error types (`NotFound`, `ValidationError`),
1199
+ - a route with no path params (`CreateUser`, `ListUsers`),
1200
+ - a route with path params (`GetUser`),
1201
+ - query params (`ListUsers`).
1202
+
1203
+ - [ ] **Step 1: Replace the file**
1204
+
1205
+ Overwrite `src/codegen/targets/kotlin/__fixtures__/users-envelope.json`:
1206
+
1207
+ ```json
1208
+ {
1209
+ "version": "1",
1210
+ "basePath": "/api",
1211
+ "headers": [],
1212
+ "routes": [
1213
+ {
1214
+ "kind": "api",
1215
+ "name": "GetUser",
1216
+ "scope": "users",
1217
+ "method": "GET",
1218
+ "fullPath": "/users/:id",
1219
+ "schema": {
1220
+ "input": {
1221
+ "pathParams": {
1222
+ "type": "object",
1223
+ "properties": { "id": { "type": "string" } },
1224
+ "required": ["id"]
1225
+ }
1226
+ },
1227
+ "returnType": {
1228
+ "type": "object",
1229
+ "properties": {
1230
+ "id": { "type": "string" },
1231
+ "name": { "type": "string" },
1232
+ "created-at": { "type": "string", "format": "date-time" },
1233
+ "address": {
1234
+ "type": "object",
1235
+ "properties": {
1236
+ "street": { "type": "string" },
1237
+ "city": { "type": "string" }
1238
+ },
1239
+ "required": ["street", "city"]
1240
+ }
1241
+ },
1242
+ "required": ["id", "name", "created-at", "address"]
1243
+ }
1244
+ },
1245
+ "errors": ["NotFound"]
1246
+ },
1247
+ {
1248
+ "kind": "api",
1249
+ "name": "CreateUser",
1250
+ "scope": "users",
1251
+ "method": "POST",
1252
+ "fullPath": "/users",
1253
+ "schema": {
1254
+ "input": {
1255
+ "body": {
1256
+ "oneOf": [
1257
+ {
1258
+ "type": "object",
1259
+ "properties": {
1260
+ "kind": { "const": "guest" },
1261
+ "displayName": { "type": "string" }
1262
+ },
1263
+ "required": ["kind", "displayName"]
1264
+ },
1265
+ {
1266
+ "type": "object",
1267
+ "properties": {
1268
+ "kind": { "const": "registered" },
1269
+ "email": { "type": "string" },
1270
+ "name": { "type": "string" }
1271
+ },
1272
+ "required": ["kind", "email", "name"]
1273
+ }
1274
+ ]
1275
+ }
1276
+ },
1277
+ "returnType": {
1278
+ "type": "object",
1279
+ "properties": { "id": { "type": "string" } },
1280
+ "required": ["id"]
1281
+ }
1282
+ },
1283
+ "errors": ["ValidationError"]
1284
+ },
1285
+ {
1286
+ "kind": "api",
1287
+ "name": "ListUsers",
1288
+ "scope": "users",
1289
+ "method": "GET",
1290
+ "fullPath": "/users",
1291
+ "schema": {
1292
+ "input": {
1293
+ "query": {
1294
+ "type": "object",
1295
+ "properties": {
1296
+ "status": { "type": "string", "enum": ["active", "inactive"] },
1297
+ "limit": { "type": "integer" }
1298
+ }
1299
+ }
1300
+ },
1301
+ "returnType": {
1302
+ "type": "object",
1303
+ "properties": {
1304
+ "items": {
1305
+ "type": "array",
1306
+ "items": {
1307
+ "type": "object",
1308
+ "properties": {
1309
+ "id": { "type": "string" },
1310
+ "name": { "type": "string" }
1311
+ },
1312
+ "required": ["id", "name"]
1313
+ }
1314
+ }
1315
+ },
1316
+ "required": ["items"]
1317
+ }
1318
+ },
1319
+ "errors": []
1320
+ }
1321
+ ],
1322
+ "errors": [
1323
+ {
1324
+ "name": "NotFound",
1325
+ "statusCode": 404,
1326
+ "description": "Resource not found",
1327
+ "schema": {
1328
+ "type": "object",
1329
+ "properties": {
1330
+ "name": { "const": "NotFound" },
1331
+ "message": { "type": "string" }
1332
+ },
1333
+ "required": ["name", "message"]
1334
+ }
1335
+ },
1336
+ {
1337
+ "name": "ValidationError",
1338
+ "statusCode": 400,
1339
+ "description": "Input failed validation",
1340
+ "schema": {
1341
+ "type": "object",
1342
+ "properties": {
1343
+ "name": { "const": "ValidationError" },
1344
+ "message": { "type": "string" },
1345
+ "field": { "type": "string" }
1346
+ },
1347
+ "required": ["name", "message"]
1348
+ }
1349
+ }
1350
+ ]
1351
+ }
1352
+ ```
1353
+
1354
+ - [ ] **Step 2: Verify the JSON is valid**
1355
+
1356
+ ```bash
1357
+ python3 -m json.tool src/codegen/targets/kotlin/__fixtures__/users-envelope.json > /dev/null
1358
+ ```
1359
+
1360
+ Expected: no output, exit code 0. (`node -e require(...)` is not used because the project is `"type": "module"`. If `python3` isn't available, `npx jq . src/codegen/targets/kotlin/__fixtures__/users-envelope.json > /dev/null` works equivalently.)
1361
+
1362
+ - [ ] **Step 3: Commit**
1363
+
1364
+ ```bash
1365
+ git add src/codegen/targets/kotlin/__fixtures__/users-envelope.json
1366
+ git commit -m "test(codegen/kotlin): replace placeholder fixture with realistic 3-route envelope"
1367
+ ```
1368
+
1369
+ ---
1370
+
1371
+ ## Task 9: Update integration test stub map and regenerate the golden
1372
+
1373
+ **Files:**
1374
+ - Modify: `src/codegen/targets/kotlin/integration.test.ts`
1375
+ - Modify: `src/codegen/targets/kotlin/__fixtures__/users-golden.kt`
1376
+
1377
+ The integration test runs the pipeline with a **stub** emitter (not real ajsc). The stub returns hand-authored ajsc-style nested-class-shape output for each slot in the new fixture. After updating the stub, regenerate the golden using `UPDATE_GOLDENS=1`.
1378
+
1379
+ - [ ] **Step 1: Add `UPDATE_GOLDENS` regeneration mode + update the stub map**
1380
+
1381
+ Replace `src/codegen/targets/kotlin/integration.test.ts` with:
1382
+
1383
+ ```ts
1384
+ import { describe, expect, it } from 'vitest'
1385
+ import { readFile, writeFile } from 'node:fs/promises'
1386
+ import { dirname, join } from 'node:path'
1387
+ import { fileURLToPath } from 'node:url'
1388
+ import { runPipeline } from '../../pipeline.js'
1389
+ import { createStubKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
1390
+
1391
+ const __filename = fileURLToPath(import.meta.url)
1392
+ const __dirname = dirname(__filename)
1393
+
1394
+ const ok = (code: string, rootTypeName: string, imports: string[] = ['kotlinx.serialization.Serializable']): KotlinEmitResult => ({
1395
+ code,
1396
+ rootTypeName,
1397
+ extractedTypeNames: [],
1398
+ imports,
1399
+ })
1400
+
1401
+ describe('kotlin codegen — integration', () => {
1402
+ it('produces byte-identical output against the golden fixture', async () => {
1403
+ const envelopePath = join(__dirname, '__fixtures__/users-envelope.json')
1404
+ const goldenPath = join(__dirname, '__fixtures__/users-golden.kt')
1405
+ const envelope = JSON.parse(await readFile(envelopePath, 'utf8'))
1406
+
1407
+ // Hand-authored slot outputs in the v7.2 nested-class shape (inlineTypes: true).
1408
+ // The exact whitespace matters — must match what emitKotlinScope produces when
1409
+ // it concatenates these blocks under `object RouteName { ... }`.
1410
+ const emitter = createStubKotlinEmitter({
1411
+ // GetUser
1412
+ PathParams: ok('@Serializable\ndata class PathParams(\n val id: String,\n)', 'PathParams'),
1413
+ Response: ok(
1414
+ '@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}',
1415
+ 'Response',
1416
+ ['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.Contextual'],
1417
+ ),
1418
+ NotFound: ok(
1419
+ '@Serializable\ndata class NotFound(\n val name: String = "NotFound",\n val message: String,\n)',
1420
+ 'NotFound',
1421
+ ),
1422
+
1423
+ // CreateUser
1424
+ Body: ok(
1425
+ '@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}',
1426
+ 'Body',
1427
+ ['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.json.JsonClassDiscriminator'],
1428
+ ),
1429
+ // CreateUser response — note the stub map is keyed on rootTypeName,
1430
+ // so we need a distinct map entry. The integration helper lookup uses
1431
+ // the slot name directly — Response is reused. Override per-route by
1432
+ // registering both stubs and keying the call site... but our stub
1433
+ // factory keys ONLY on rootTypeName. Since both routes' Response slots
1434
+ // have rootTypeName="Response", we provide one entry and accept that
1435
+ // both routes get the same stubbed Response. For golden integrity that's
1436
+ // fine — the integration test pins file assembly, not ajsc semantics.
1437
+ // CreateUser's Response is the one currently registered above.
1438
+ ValidationError: ok(
1439
+ '@Serializable\ndata class ValidationError(\n val name: String = "ValidationError",\n val message: String,\n val field: String? = null,\n)',
1440
+ 'ValidationError',
1441
+ ),
1442
+
1443
+ // ListUsers
1444
+ Query: ok(
1445
+ '@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}',
1446
+ 'Query',
1447
+ ['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName'],
1448
+ ),
1449
+ })
1450
+
1451
+ // NOTE: GetUser.Response and CreateUser.Response collide on the stub map
1452
+ // (same rootTypeName). The integration test consciously accepts this —
1453
+ // rebuild the map at the call site if more granularity is needed later.
1454
+ // For now the second Response usage in CreateUser will reuse the GetUser
1455
+ // Response stub. We rewrite to a discriminated stub:
1456
+ //
1457
+ // (ignore — see how the route emitter uses rootTypeName 'Response' for
1458
+ // both routes; a single stub entry is fine for golden purposes.)
1459
+
1460
+ const files = await runPipeline({
1461
+ envelope, outDir: 'out', dryRun: true,
1462
+ target: 'kotlin', kotlinPackage: 'com.example.api',
1463
+ kotlinEmitter: emitter,
1464
+ })
1465
+
1466
+ expect(files).toHaveLength(1)
1467
+ expect(files[0]!.path).toBe(join('out', 'Users.kt'))
1468
+ const produced = files[0]!.code
1469
+
1470
+ if (process.env.UPDATE_GOLDENS === '1') {
1471
+ // Replace the source-hash line with the placeholder so the golden is portable.
1472
+ const goldenContent = produced.replace(/^\/\/ Source hash: [a-f0-9]+$/m, '// Source hash: <PLACEHOLDER>')
1473
+ await writeFile(goldenPath, goldenContent, 'utf-8')
1474
+ // eslint-disable-next-line no-console
1475
+ console.log(`[integration.test] Wrote golden: ${goldenPath}`)
1476
+ return
1477
+ }
1478
+
1479
+ const goldenTemplate = await readFile(goldenPath, 'utf8')
1480
+ const sourceHashLine = produced.split('\n').find((l) => l.startsWith('// Source hash:')) ?? ''
1481
+ const goldenWithHash = goldenTemplate.replace('// Source hash: <PLACEHOLDER>', sourceHashLine)
1482
+ expect(produced).toBe(goldenWithHash)
1483
+ })
1484
+ })
1485
+ ```
1486
+
1487
+ > **Note on the GetUser/CreateUser Response collision:** the stub map is keyed on `rootTypeName`, so both routes' `Response` slots resolve to the same stubbed value. This is acceptable because the integration test pins our **file assembly logic**, not ajsc's per-route semantics — any divergence in real ajsc output would surface in the kotlinc E2E test (Task 11) instead.
1488
+
1489
+ - [ ] **Step 2: Run the test in regenerate mode**
1490
+
1491
+ ```bash
1492
+ UPDATE_GOLDENS=1 npx vitest run src/codegen/targets/kotlin/integration.test.ts
1493
+ ```
1494
+
1495
+ Expected: PASS (writes the golden). Inspect the produced file:
1496
+
1497
+ ```bash
1498
+ cat src/codegen/targets/kotlin/__fixtures__/users-golden.kt
1499
+ ```
1500
+
1501
+ It should contain `package com.example.api`, `// Source hash: <PLACEHOLDER>`, deduped imports (kotlinx.serialization.*), and `object Users { object GetUser { ... } object CreateUser { ... } object ListUsers { ... } }` with each route's nested data classes.
1502
+
1503
+ - [ ] **Step 3: Run the test in normal mode**
1504
+
1505
+ ```bash
1506
+ npx vitest run src/codegen/targets/kotlin/integration.test.ts
1507
+ ```
1508
+
1509
+ Expected: PASS — produced output matches the golden modulo the source-hash splice.
1510
+
1511
+ - [ ] **Step 4: Commit**
1512
+
1513
+ ```bash
1514
+ git add src/codegen/targets/kotlin/integration.test.ts src/codegen/targets/kotlin/__fixtures__/users-golden.kt
1515
+ git commit -m "test(codegen/kotlin): integration test against realistic fixture with golden regen mode"
1516
+ ```
1517
+
1518
+ ---
1519
+
1520
+ ## Task 10: Probe test for `unsupportedUnions: 'fallback'` Kotlin behavior
1521
+
1522
+ **Files:**
1523
+ - Create: `src/codegen/targets/kotlin/probe-unsupported-unions.test.ts`
1524
+
1525
+ ajsc's v7.2 handoff documents the **Swift** `AnyCodable` fallback shape but does not specify what Kotlin emits in fallback mode. We need ground truth before documenting the flag in `docs/codegen-kotlin.md`. Snapshot test, gated on ajsc resolvability.
1526
+
1527
+ - [ ] **Step 1: Create the probe test**
1528
+
1529
+ Create `src/codegen/targets/kotlin/probe-unsupported-unions.test.ts`:
1530
+
1531
+ ```ts
1532
+ import { describe, it, expect } from 'vitest'
1533
+
1534
+ let ajscResolvable = false
1535
+ let emitKotlinFn: ((schema: unknown, opts: unknown) => { code: string; imports: string[]; rootTypeName: string; extractedTypeNames: string[] }) | undefined
1536
+
1537
+ try {
1538
+ const ajsc = await import('ajsc')
1539
+ if (typeof (ajsc as { emitKotlin?: unknown }).emitKotlin === 'function') {
1540
+ ajscResolvable = true
1541
+ emitKotlinFn = (ajsc as { emitKotlin: typeof emitKotlinFn }).emitKotlin!
1542
+ }
1543
+ } catch {
1544
+ // ajsc not installed (e.g. npm install --omit=optional); test skips below.
1545
+ }
1546
+
1547
+ describe('ajsc.emitKotlin — unsupportedUnions: fallback', () => {
1548
+ it.skipIf(!ajscResolvable)(
1549
+ 'produces a deterministic fallback shape for an untagged oneOf',
1550
+ () => {
1551
+ const schema = {
1552
+ oneOf: [{ type: 'string' }, { type: 'integer' }],
1553
+ }
1554
+ const result = emitKotlinFn!(schema, {
1555
+ rootTypeName: 'Mixed',
1556
+ inlineTypes: true,
1557
+ unsupportedUnions: 'fallback',
1558
+ })
1559
+
1560
+ // Snapshot pins the current ajsc behavior. If ajsc changes the fallback
1561
+ // shape, this diff prompts an intentional review and a corresponding
1562
+ // update to docs/codegen-kotlin.md.
1563
+ expect({
1564
+ code: result.code,
1565
+ imports: result.imports.slice().sort(),
1566
+ rootTypeName: result.rootTypeName,
1567
+ extractedTypeNames: result.extractedTypeNames.slice().sort(),
1568
+ }).toMatchSnapshot()
1569
+ },
1570
+ )
1571
+
1572
+ it.skipIf(!ajscResolvable)(
1573
+ 'throws when unsupportedUnions defaults to throw',
1574
+ () => {
1575
+ const schema = { oneOf: [{ type: 'string' }, { type: 'integer' }] }
1576
+ expect(() => emitKotlinFn!(schema, { rootTypeName: 'Mixed', inlineTypes: true })).toThrow()
1577
+ },
1578
+ )
1579
+ })
1580
+ ```
1581
+
1582
+ - [ ] **Step 2: Run the probe**
1583
+
1584
+ ```bash
1585
+ npx vitest run src/codegen/targets/kotlin/probe-unsupported-unions.test.ts
1586
+ ```
1587
+
1588
+ Expected: PASS (writes a `.snap` file under `__snapshots__/`). If ajsc isn't installed, both tests SKIP — that's fine.
1589
+
1590
+ - [ ] **Step 3: Inspect the captured snapshot**
1591
+
1592
+ ```bash
1593
+ cat src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap
1594
+ ```
1595
+
1596
+ Note the actual `code` and `imports` produced. **This is the ground truth for Task 12's `docs/codegen-kotlin.md` "untagged unions" section.** Save the contents (or just reference the snap file path) for that task.
1597
+
1598
+ - [ ] **Step 4: Commit**
1599
+
1600
+ ```bash
1601
+ git add src/codegen/targets/kotlin/probe-unsupported-unions.test.ts src/codegen/targets/kotlin/__snapshots__/
1602
+ git commit -m "test(codegen/kotlin): probe ajsc unsupportedUnions=fallback shape via snapshot"
1603
+ ```
1604
+
1605
+ ---
1606
+
1607
+ ## Task 11: Activate kotlinc E2E (drop ajsc-availability gate)
1608
+
1609
+ **Files:**
1610
+ - Modify: `src/codegen/targets/kotlin/e2e-compile.test.ts`
1611
+
1612
+ ajsc Phase A is delivered — drop the ajsc-availability check. Keep the `kotlinc` toolchain check + `TS_PROCEDURES_KOTLIN_E2E=1` opt-in env var. Update fixture references (already point at `users-envelope.json`; that file got more interesting in Task 8). Document the kotlinx-serialization-json classpath requirement in a comment so contributors know what to set up locally.
1613
+
1614
+ - [ ] **Step 1: Update the test to use only the toolchain + opt-in gate**
1615
+
1616
+ Replace the existing `src/codegen/targets/kotlin/e2e-compile.test.ts` with:
1617
+
1618
+ ```ts
1619
+ import { describe, it, expect } from 'vitest'
1620
+ import { execSync } from 'node:child_process'
1621
+ import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'
1622
+ import { tmpdir } from 'node:os'
1623
+ import { dirname, join } from 'node:path'
1624
+ import { fileURLToPath } from 'node:url'
1625
+ import { runPipeline } from '../../pipeline.js'
1626
+ import { resolveProductionKotlinEmitter } from './ajsc-adapter.js'
1627
+
1628
+ const __filename = fileURLToPath(import.meta.url)
1629
+ const __dirname = dirname(__filename)
1630
+
1631
+ function kotlincAvailable(): boolean {
1632
+ try {
1633
+ execSync('kotlinc -version', { stdio: 'ignore' })
1634
+ return true
1635
+ } catch {
1636
+ return false
1637
+ }
1638
+ }
1639
+
1640
+ const RUN = process.env.TS_PROCEDURES_KOTLIN_E2E === '1'
1641
+
1642
+ /**
1643
+ * E2E: real ajsc → real .kt output → real kotlinc compile.
1644
+ *
1645
+ * Gated on (a) `kotlinc` on PATH and (b) opt-in via env var so default
1646
+ * `npm test` runs stay green for contributors without the toolchain.
1647
+ *
1648
+ * **Classpath setup (one-time, local):** download
1649
+ * `kotlinx-serialization-json-jvm-<ver>.jar` and
1650
+ * `kotlinx-serialization-core-jvm-<ver>.jar`
1651
+ * from Maven Central, place under `~/.m2/repo/...` (or any directory),
1652
+ * and set `TS_PROCEDURES_KOTLIN_E2E_CLASSPATH=/path/with:globs.jar` to
1653
+ * inject them into the kotlinc invocation. If the env var is unset the
1654
+ * compile uses kotlinc's default classpath which lacks the kotlinx jars
1655
+ * and fails — that's the expected mode when checking gating works.
1656
+ */
1657
+ describe('kotlin codegen — kotlinc compile (gated)', () => {
1658
+ it.skipIf(!kotlincAvailable() || !RUN)(
1659
+ 'compiles generated output without errors',
1660
+ async () => {
1661
+ const emitter = await resolveProductionKotlinEmitter()
1662
+ const envelope = JSON.parse(
1663
+ readFileSync(join(__dirname, '__fixtures__/users-envelope.json'), 'utf8'),
1664
+ )
1665
+ const files = await runPipeline({
1666
+ envelope,
1667
+ outDir: 'out',
1668
+ dryRun: true,
1669
+ target: 'kotlin',
1670
+ kotlinPackage: 'com.example.api',
1671
+ kotlinEmitter: emitter,
1672
+ })
1673
+ const dir = mkdtempSync(join(tmpdir(), 'tsp-kotlin-e2e-'))
1674
+ for (const f of files) {
1675
+ writeFileSync(join(dir, f.path.split('/').pop()!), f.code)
1676
+ }
1677
+ const cp = process.env.TS_PROCEDURES_KOTLIN_E2E_CLASSPATH
1678
+ const cpFlag = cp != null && cp.length > 0 ? `-classpath ${cp}` : ''
1679
+ execSync(`kotlinc ${cpFlag} ${dir}/*.kt -d ${dir}/out.jar`, { stdio: 'inherit' })
1680
+ expect(true).toBe(true)
1681
+ },
1682
+ )
1683
+ })
1684
+ ```
1685
+
1686
+ - [ ] **Step 2: Run locally to verify gating**
1687
+
1688
+ ```bash
1689
+ npx vitest run src/codegen/targets/kotlin/e2e-compile.test.ts
1690
+ ```
1691
+
1692
+ Expected: SKIPPED (with reason) when `TS_PROCEDURES_KOTLIN_E2E !== '1'`.
1693
+
1694
+ - [ ] **Step 3: (Optional, if you have kotlinc + jars) verify the test compiles**
1695
+
1696
+ ```bash
1697
+ TS_PROCEDURES_KOTLIN_E2E=1 \
1698
+ TS_PROCEDURES_KOTLIN_E2E_CLASSPATH=~/Downloads/kotlinx-serialization-json-jvm-*.jar:~/Downloads/kotlinx-serialization-core-jvm-*.jar \
1699
+ npx vitest run src/codegen/targets/kotlin/e2e-compile.test.ts
1700
+ ```
1701
+
1702
+ Expected: PASS. If FAIL on missing imports, the classpath jars are wrong — adjust the env var.
1703
+
1704
+ - [ ] **Step 4: Commit**
1705
+
1706
+ ```bash
1707
+ git add src/codegen/targets/kotlin/e2e-compile.test.ts
1708
+ git commit -m "test(codegen/kotlin): activate kotlinc E2E (toolchain-gated only); document classpath setup"
1709
+ ```
1710
+
1711
+ ---
1712
+
1713
+ ## Task 12: Create downstream consumer setup guide
1714
+
1715
+ **Files:**
1716
+ - Create: `docs/codegen-kotlin.md`
1717
+
1718
+ The single highest-DX-impact deliverable in the plan. Use the snapshot from Task 10 to fill in the **Untagged unions** section with concrete content (don't speculate).
1719
+
1720
+ - [ ] **Step 1: Author the guide**
1721
+
1722
+ Create `docs/codegen-kotlin.md`:
1723
+
1724
+ ````markdown
1725
+ # Kotlin Codegen Setup Guide
1726
+
1727
+ Generated by `ts-procedures-codegen --target kotlin`. One `.kt` file per scope; types are nested under route objects (`Users.GetUser.Response`, `Users.GetUser.Body.Address`).
1728
+
1729
+ ## Quickstart
1730
+
1731
+ ```bash
1732
+ npx ts-procedures-codegen \
1733
+ --target kotlin \
1734
+ --kotlin-package com.example.api \
1735
+ --url https://api.example.com/_ts-procedures.json \
1736
+ --out ./src/main/kotlin/com/example/api
1737
+ ```
1738
+
1739
+ Each scope produces one file (e.g. `Users.kt`). Access generated types as `Users.GetUser.Response`, `Users.GetUser.Body.GuestBody`, `Users.GetUser.Errors.NotFound`.
1740
+
1741
+ ## Gradle setup
1742
+
1743
+ The default `--kotlin-serializer kotlinx` mode emits `@Serializable` data classes. Add the kotlinx-serialization plugin and runtime:
1744
+
1745
+ ```kotlin
1746
+ // build.gradle.kts
1747
+ plugins {
1748
+ kotlin("jvm") version "<your-kotlin-version>"
1749
+ kotlin("plugin.serialization") version "<your-kotlin-version>"
1750
+ }
1751
+
1752
+ dependencies {
1753
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:<version>")
1754
+ }
1755
+ ```
1756
+
1757
+ **JVM only.** Kotlin Multiplatform is not yet supported. If you need KMP-portable types, fall back to `--kotlin-serializer none` or post-process the emitted code.
1758
+
1759
+ ## Contextual serializers
1760
+
1761
+ `format: date-time`, `format: uuid`, `format: uri`, `format: date`, `format: time` map to JVM stdlib types (`java.time.Instant`, `java.util.UUID`, etc.) annotated with `@Contextual`. Kotlinx-serialization does not know how to serialize these natively — register contextual serializers in your `Json` configuration:
1762
+
1763
+ ```kotlin
1764
+ import kotlinx.serialization.json.Json
1765
+ import kotlinx.serialization.modules.SerializersModule
1766
+ import kotlinx.serialization.modules.contextual
1767
+
1768
+ val json = Json {
1769
+ serializersModule = SerializersModule {
1770
+ contextual(java.time.Instant::class, InstantSerializer)
1771
+ contextual(java.util.UUID::class, UUIDSerializer)
1772
+ contextual(java.net.URI::class, URISerializer)
1773
+ // ...etc for any format you use
1774
+ }
1775
+ }
1776
+ ```
1777
+
1778
+ The serializers themselves are your responsibility (we don't ship them — the choice between ISO-8601 strings, epoch milliseconds, etc. is application-specific). Public Gist-quality implementations are widely available; we recommend ISO-8601 to match the server.
1779
+
1780
+ ## Discriminated unions
1781
+
1782
+ A schema with a `oneOf` whose variants share a const-valued discriminator (e.g. `kind: "guest" | "registered"`) emits as a sealed interface:
1783
+
1784
+ ```kotlin
1785
+ @Serializable
1786
+ @JsonClassDiscriminator("kind")
1787
+ sealed interface Body {
1788
+ @Serializable
1789
+ @SerialName("guest")
1790
+ data class GuestBody(val displayName: String) : Body
1791
+
1792
+ @Serializable
1793
+ @SerialName("registered")
1794
+ data class RegisteredBody(val email: String, val name: String) : Body
1795
+ }
1796
+ ```
1797
+
1798
+ The discriminator field (`kind`) is **erased** from each variant under `--kotlin-serializer kotlinx` — `@SerialName` carries it on the wire. With `--kotlin-serializer none` the discriminator field is retained.
1799
+
1800
+ `@JsonClassDiscriminator` is read automatically by the kotlinx `Json` instance — no extra config needed.
1801
+
1802
+ ## JSON-key sanitization
1803
+
1804
+ Kebab-case and snake-case JSON keys become camelCase property names with `@SerialName` auto-emitted so the wire format stays correct:
1805
+
1806
+ ```kotlin
1807
+ @Serializable
1808
+ data class Response(
1809
+ @SerialName("created-at")
1810
+ @Contextual
1811
+ val createdAt: java.time.Instant,
1812
+ )
1813
+ ```
1814
+
1815
+ Reserved Kotlin words get a trailing underscore (`class` → `class_`).
1816
+
1817
+ ## Switching off kotlinx (Moshi, Gson, hand-written)
1818
+
1819
+ `--kotlin-serializer none` emits plain `data class` types with no `@Serializable` annotation. You're then responsible for adapter setup:
1820
+
1821
+ - **Reflection-based libraries** (Gson) work without further changes.
1822
+ - **Codegen-based** (Moshi with codegen) need their own annotation layer added.
1823
+ - Sealed interfaces still emit; the discriminator field is **retained** in variants under `none` mode.
1824
+
1825
+ ## Untagged unions: `--unsupported-unions fallback`
1826
+
1827
+ By default, an untagged `oneOf` (e.g. `oneOf: [{ type: 'string' }, { type: 'integer' }]`) throws at codegen time with a path-bearing error message. Pass `--unsupported-unions fallback` to emit a placeholder shape instead.
1828
+
1829
+ > **Fill this section in from the snapshot at `src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap`** — capture the actual `code` and `imports` produced. Do not speculate; the implementer should literally copy the snapshot content into this section verbatim before merging this PR.
1830
+
1831
+ ## Documented limitations
1832
+
1833
+ The following ajsc behaviors are intentional and documented; they are **not bugs**:
1834
+
1835
+ - `additionalProperties: { type: T }` is silently dropped with a `/** Note: schema permits additional keys of type T — not modeled. */` KDoc note. If your contract uses extra keys, add a sibling `Map<String, T>` field by hand or write a custom `KSerializer`.
1836
+ - Tuples with 4 or more positional types throw (`Pair`/`Triple` only). Refactor to a struct schema upstream.
1837
+ - Schema-level `examples` and `not` / `patternProperties` are not modeled (the latter throw with a path-bearing error).
1838
+
1839
+ ## Reference
1840
+
1841
+ - Spec: [`docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md`](./superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md)
1842
+ - ajsc README: `node_modules/ajsc/README.md` (or [npmjs.com/package/ajsc](https://www.npmjs.com/package/ajsc))
1843
+ - ts-procedures-codegen CLI flags: see `npx ts-procedures-codegen --help` (TODO: add `--help` if not yet implemented)
1844
+ ````
1845
+
1846
+ - [ ] **Step 2: Replace the placeholder "Untagged unions" subsection with the probe snapshot content**
1847
+
1848
+ Read the snapshot file:
1849
+
1850
+ ```bash
1851
+ cat src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap
1852
+ ```
1853
+
1854
+ Copy the `code` value and the sorted `imports` list from the snapshot into the "Untagged unions" section of `docs/codegen-kotlin.md`, replacing the `> **Fill this section in...` block with a concrete code fence showing what ajsc emits.
1855
+
1856
+ - [ ] **Step 3: Verify no placeholder remains**
1857
+
1858
+ ```bash
1859
+ grep -n 'Fill this section in' docs/codegen-kotlin.md && { echo 'ERROR: Untagged unions placeholder still present — copy the probe snapshot content in.' >&2; exit 1; } || echo 'OK: no placeholder text remains.'
1860
+ ```
1861
+
1862
+ Expected: `OK: no placeholder text remains.` and exit 0. If the placeholder string is found, go back to Step 2 and copy the snapshot content in.
1863
+
1864
+ - [ ] **Step 4: Verify the doc renders sensibly**
1865
+
1866
+ ```bash
1867
+ # Visual check (cat or open in your editor)
1868
+ cat docs/codegen-kotlin.md | head -50
1869
+ ```
1870
+
1871
+ - [ ] **Step 5: Commit**
1872
+
1873
+ ```bash
1874
+ git add docs/codegen-kotlin.md
1875
+ git commit -m "docs(codegen/kotlin): add downstream consumer setup guide"
1876
+ ```
1877
+
1878
+ ---
1879
+
1880
+ ## Task 13: Update `CLAUDE.md` Kotlin target bullet
1881
+
1882
+ **Files:**
1883
+ - Modify: `CLAUDE.md`
1884
+
1885
+ - [ ] **Step 1: Update the Kotlin target bullet**
1886
+
1887
+ In `CLAUDE.md`, find the section "Client Code Generation" and the existing bullet starting with `- **Kotlin target** (\`--target kotlin\`)`. Replace with:
1888
+
1889
+ ```markdown
1890
+ - **Kotlin target** (`--target kotlin`): emits one `.kt` file per scope with nested `object` namespaces (`Users.GetUser`, `Users.GetUser.Body`, `Users.GetUser.Body.Address` — types are emitted with `inlineTypes: true` so nested objects nest as Kotlin nested classes). HTTP method constants, path templates, and path-builder functions (`fun path(p: PathParams)`) for routes with path params; a `const val path` for routes without. Types are emitted by `ajsc.emitKotlin` with `kotlinx.serialization` annotations by default. **No runtime, no adapter, no error registry** — mobile devs own the HTTP layer entirely. Requires `--kotlin-package <com.example.api>` (or `kotlin.package` in the config file). Optional flags: `--kotlin-serializer <kotlinx|none>` (default `kotlinx` — emits `@Serializable`; pass `none` for plain data classes), `--unsupported-unions <throw|fallback>` (default `throw` — surfaces ajsc's escape hatch for untagged `oneOf` schemas).
1891
+ - Streams, hooks, per-call options, and error dispatch logic are deliberately out of scope for the Kotlin target. See `docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md` for the polish design and `docs/codegen-kotlin.md` for downstream consumer setup (Gradle deps, contextual serializers, sealed interfaces).
1892
+ ```
1893
+
1894
+ (Replace the existing two-bullet group; the previous spec reference becomes the new spec.)
1895
+
1896
+ - [ ] **Step 2: Run the build to confirm CLAUDE.md is well-formed (no required validation, but a smoke check)**
1897
+
1898
+ ```bash
1899
+ git diff CLAUDE.md
1900
+ ```
1901
+
1902
+ Verify the diff is what you expect — bullet replaced, no surrounding sections damaged.
1903
+
1904
+ - [ ] **Step 3: Commit**
1905
+
1906
+ ```bash
1907
+ git add CLAUDE.md
1908
+ git commit -m "docs: update CLAUDE.md kotlin target bullet for v7.2 polish"
1909
+ ```
1910
+
1911
+ ---
1912
+
1913
+ ## Task 14: Final verification
1914
+
1915
+ **Files:** none.
1916
+
1917
+ Sanity-check the full codegen suite plus a manual end-to-end run.
1918
+
1919
+ - [ ] **Step 1: Run all tests**
1920
+
1921
+ ```bash
1922
+ npm run test 2>&1 | tail -30
1923
+ ```
1924
+
1925
+ Expected: all PASS (E2E compile test SKIPS unless `TS_PROCEDURES_KOTLIN_E2E=1` and `kotlinc` available).
1926
+
1927
+ - [ ] **Step 2: Run lint and build**
1928
+
1929
+ ```bash
1930
+ npm run lint && npm run build
1931
+ ```
1932
+
1933
+ Expected: both succeed.
1934
+
1935
+ - [ ] **Step 3: Manual end-to-end smoke test using the production resolver**
1936
+
1937
+ ```bash
1938
+ npx ts-procedures-codegen \
1939
+ --target kotlin \
1940
+ --kotlin-package com.example.api \
1941
+ --file src/codegen/targets/kotlin/__fixtures__/users-envelope.json \
1942
+ --out /tmp/kotlin-out
1943
+ cat /tmp/kotlin-out/Users.kt
1944
+ ```
1945
+
1946
+ Expected: a real `.kt` file containing `package com.example.api`, `// Source hash: ...`, deduped imports, and `object Users { object GetUser { ... } object CreateUser { ... } object ListUsers { ... } }` with nested data classes. The setup-guide pointer should print at the end:
1947
+
1948
+ ```
1949
+ [ts-procedures-codegen] Kotlin setup guide: https://bitbucket.org/thermsio/ts-procedures/src/master/docs/codegen-kotlin.md
1950
+ ```
1951
+
1952
+ - [ ] **Step 4: (If kotlinc available) run the E2E**
1953
+
1954
+ ```bash
1955
+ TS_PROCEDURES_KOTLIN_E2E=1 \
1956
+ TS_PROCEDURES_KOTLIN_E2E_CLASSPATH=<path-to-jars> \
1957
+ npx vitest run src/codegen/targets/kotlin/e2e-compile.test.ts
1958
+ ```
1959
+
1960
+ Expected: PASS.
1961
+
1962
+ ---
1963
+
1964
+ ## Sequencing & dependencies
1965
+
1966
+ | Task | Depends on | Blocks |
1967
+ |---|---|---|
1968
+ | 1. KotlinEmitOptions cleanup | — | 3 |
1969
+ | 2. Resolver TODO drop | 1 | — |
1970
+ | 3. emit-route inlineTypes + opts | 1 | 4 |
1971
+ | 4. emit-scope opts + skippedStreams | 3 | 5 |
1972
+ | 5. Pipeline opts + summary | 4 | 6 |
1973
+ | 6. CLI flags | 5 | 7 |
1974
+ | 7. CLI setup-guide pointer | 6 | — |
1975
+ | 8. Realistic fixture | — | 9, 10, 11 |
1976
+ | 9. Integration golden regen | 4, 8 | — |
1977
+ | 10. Probe test | 1, 8 | 12 |
1978
+ | 11. kotlinc E2E activation | 8 | — |
1979
+ | 12. Consumer doc | 10 | — |
1980
+ | 13. CLAUDE.md | 6, 7 | — |
1981
+ | 14. Final verification | all | — |
1982
+
1983
+ Tasks 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 are sequenced linearly above. Tasks 8, 10, 11 can run in any order relative to 1–7 (data and ajsc-only). For agentic execution, follow the order as written for cleanest dependency tracking.
1984
+
1985
+ ## Definition of done
1986
+
1987
+ - All tests in `src/codegen/targets/kotlin/` pass via `npx vitest run src/codegen/targets/kotlin/`.
1988
+ - Probe snapshot exists and matches what ajsc currently emits for `unsupportedUnions: 'fallback'`.
1989
+ - The `__fixtures__/users-golden.kt` matches the produced output exactly (modulo source-hash splice).
1990
+ - `npm run test`, `npm run lint`, `npm run build` all succeed.
1991
+ - A manual `npx ts-procedures-codegen --target kotlin ...` run produces a realistic `Users.kt` and prints the setup-guide pointer.
1992
+ - The kotlinc E2E test (Task 11) runs locally with `TS_PROCEDURES_KOTLIN_E2E=1` and exits 0 (manual verification before merge — not required in CI unless someone wires up `kotlinc` provisioning separately).
1993
+ - `docs/codegen-kotlin.md` exists, has a real "Untagged unions" section sourced from the probe snapshot, and links from `CLAUDE.md`.