ts-procedures 6.0.1 → 6.0.2

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 (36) hide show
  1. package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +24 -0
  2. package/build/codegen/targets/kotlin/ajsc-adapter.js +33 -0
  3. package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -0
  4. package/build/codegen/targets/kotlin/ajsc-adapter.test.d.ts +1 -0
  5. package/build/codegen/targets/kotlin/ajsc-adapter.test.js +19 -0
  6. package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -0
  7. package/build/codegen/targets/kotlin/e2e-compile.test.d.ts +1 -0
  8. package/build/codegen/targets/kotlin/e2e-compile.test.js +43 -0
  9. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -0
  10. package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +11 -0
  11. package/build/codegen/targets/kotlin/emit-route-kotlin.js +73 -0
  12. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -0
  13. package/build/codegen/targets/kotlin/emit-route-kotlin.test.d.ts +1 -0
  14. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +88 -0
  15. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -0
  16. package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +11 -0
  17. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +35 -0
  18. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -0
  19. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.d.ts +1 -0
  20. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +52 -0
  21. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -0
  22. package/build/codegen/targets/kotlin/format-kotlin.d.ts +4 -0
  23. package/build/codegen/targets/kotlin/format-kotlin.js +20 -0
  24. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -0
  25. package/build/codegen/targets/kotlin/format-kotlin.test.d.ts +1 -0
  26. package/build/codegen/targets/kotlin/format-kotlin.test.js +24 -0
  27. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -0
  28. package/build/codegen/targets/kotlin/integration.test.d.ts +1 -0
  29. package/build/codegen/targets/kotlin/integration.test.js +34 -0
  30. package/build/codegen/targets/kotlin/integration.test.js.map +1 -0
  31. package/docs/http-integrations.md +32 -0
  32. package/docs/superpowers/plans/2026-04-24-kotlin-codegen-target.md +1265 -0
  33. package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +401 -0
  34. package/package.json +1 -1
  35. package/src/implementations/http/README.md +2 -0
  36. package/src/implementations/http/hono-stream/README.md +15 -0
@@ -0,0 +1,1265 @@
1
+ # Kotlin Codegen Target 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:** Add a `--target kotlin` mode to `ts-procedures-codegen` that emits one `.kt` file per scope, containing nested `object` namespaces with HTTP method constants, path-template constants, path-builder functions, and types delegated to `ajsc.emitKotlin`. No runtime, no adapter, no error registry — types and URL/method primitives only.
6
+
7
+ **Architecture:** New `src/codegen/targets/kotlin/` directory housing scope/route emitters. Pipeline dispatches on a new `target` option. Type emission is delegated to `ajsc.emitKotlin` via a thin adapter that returns the explicit `EmitResult` contract (`code`, `rootTypeName`, `extractedTypeNames`, `imports`). Tests use a stubbed emitter so this work can ship before ajsc Phase A; one E2E compile test is gated behind ajsc availability.
8
+
9
+ **Tech Stack:** TypeScript, Vitest, Node.js. Cross-repo dependency: `ajsc` package's Kotlin emitter (Phase A) — stubbed for unit/integration tests, real for E2E compile test.
10
+
11
+ **Spec:** [`docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md`](../specs/2026-04-24-kotlin-swift-codegen-design.md)
12
+
13
+ **Cross-repo dependency:** Phase A of the spec (ajsc Kotlin emitter) is independent work and lives in the `ajsc` repo. This plan covers Phase B only and ships fully testable software using a stubbed emitter. The kotlinc E2E test (Task 9) is gated until ajsc Phase A delivers.
14
+
15
+ ---
16
+
17
+ ## File Structure
18
+
19
+ **New files:**
20
+
21
+ | Path | Responsibility |
22
+ |---|---|
23
+ | `src/codegen/targets/kotlin/ajsc-adapter.ts` | Thin wrapper around `ajsc.emitKotlin`; exposes `KotlinEmitter` interface + stub factory for tests. |
24
+ | `src/codegen/targets/kotlin/format-kotlin.ts` | Pure formatting helpers: package decl, source-hash header, deduped imports, indentation. |
25
+ | `src/codegen/targets/kotlin/emit-route-kotlin.ts` | Emits one `object RouteName { ... }` block: method const, path template, path constant/fn, types via ajsc, `Errors` namespace. |
26
+ | `src/codegen/targets/kotlin/emit-scope-kotlin.ts` | Emits one `.kt` file per scope: package + imports + outer `object Scope { <routes> }` + source hash. |
27
+ | `src/codegen/targets/kotlin/__fixtures__/users-envelope.json` | Fixture DocEnvelope for integration tests. |
28
+ | `src/codegen/targets/kotlin/__fixtures__/users-golden.kt` | Golden Kotlin output for byte-identical assertion. |
29
+ | `src/codegen/targets/kotlin/*.test.ts` | One test file per source file. |
30
+
31
+ **Modified files:**
32
+
33
+ | Path | Change |
34
+ |---|---|
35
+ | `src/codegen/bin/cli.ts` | Add `--target` and `--kotlin-package` flags; extend `CodegenConfig`/`ParsedArgs`; validate kotlin requires package. |
36
+ | `src/codegen/pipeline.ts` | Extend `PipelineOptions` with `target` + `kotlinPackage`; dispatch to Kotlin emitter, skip TS-only outputs (errors/index/client-runtime files). |
37
+ | `src/codegen/index.ts` | Surface `target` + `kotlinPackage` via `GenerateClientOptions`. |
38
+
39
+ The `targets/kotlin/` directory is intentionally self-contained so a future split into `@ts-procedures/codegen-kotlin` is a directory move, not a refactor.
40
+
41
+ ---
42
+
43
+ ## Task 1: Define `KotlinEmitter` adapter contract with test stub
44
+
45
+ **Files:**
46
+ - Create: `src/codegen/targets/kotlin/ajsc-adapter.ts`
47
+ - Create: `src/codegen/targets/kotlin/ajsc-adapter.test.ts`
48
+
49
+ This task introduces the contract before any consumer needs it. Production wiring to ajsc is deferred to Task 7; for now we only need the type + a stub factory so downstream tasks can test against a deterministic emitter.
50
+
51
+ - [ ] **Step 1: Write the failing test**
52
+
53
+ Write `src/codegen/targets/kotlin/ajsc-adapter.test.ts`:
54
+
55
+ ```ts
56
+ import { describe, expect, it } from 'vitest'
57
+ import { createStubKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
58
+
59
+ describe('createStubKotlinEmitter', () => {
60
+ it('returns the configured EmitResult for the matching root name', () => {
61
+ const expected: KotlinEmitResult = {
62
+ code: '@Serializable data class User(val id: String)',
63
+ rootTypeName: 'User',
64
+ extractedTypeNames: [],
65
+ imports: ['kotlinx.serialization.Serializable'],
66
+ }
67
+ const emitter = createStubKotlinEmitter({ User: expected })
68
+ expect(emitter.emit({ type: 'object' }, { rootTypeName: 'User' })).toEqual(expected)
69
+ })
70
+
71
+ it('throws when asked to emit a name not in the stub map', () => {
72
+ const emitter = createStubKotlinEmitter({})
73
+ expect(() => emitter.emit({}, { rootTypeName: 'Missing' })).toThrow(/Missing/)
74
+ })
75
+ })
76
+ ```
77
+
78
+ - [ ] **Step 2: Run the test and verify it fails**
79
+
80
+ ```bash
81
+ npx vitest run src/codegen/targets/kotlin/ajsc-adapter.test.ts
82
+ ```
83
+
84
+ Expected: FAIL — module `./ajsc-adapter.js` not found.
85
+
86
+ - [ ] **Step 3: Write minimal implementation**
87
+
88
+ Write `src/codegen/targets/kotlin/ajsc-adapter.ts`:
89
+
90
+ ```ts
91
+ import type { JSONSchema7 } from 'json-schema'
92
+
93
+ export interface KotlinEmitResult {
94
+ code: string
95
+ rootTypeName: string
96
+ extractedTypeNames: string[]
97
+ imports: string[]
98
+ }
99
+
100
+ export interface KotlinEmitOptions {
101
+ rootTypeName: string
102
+ inlineTypes?: boolean
103
+ serializer?: 'kotlinx'
104
+ enumStyle?: string
105
+ depluralize?: boolean
106
+ arrayItemNaming?: string
107
+ uncountableWords?: string[]
108
+ }
109
+
110
+ export interface KotlinEmitter {
111
+ emit(schema: JSONSchema7 | Record<string, unknown>, opts: KotlinEmitOptions): KotlinEmitResult
112
+ }
113
+
114
+ export function createStubKotlinEmitter(
115
+ results: Record<string, KotlinEmitResult>,
116
+ ): KotlinEmitter {
117
+ return {
118
+ emit(_schema, opts) {
119
+ const result = results[opts.rootTypeName]
120
+ if (result == null) {
121
+ throw new Error(
122
+ `[stub-kotlin-emitter] No stubbed result for rootTypeName "${opts.rootTypeName}". ` +
123
+ `Provide one in the results map.`,
124
+ )
125
+ }
126
+ return result
127
+ },
128
+ }
129
+ }
130
+ ```
131
+
132
+ - [ ] **Step 4: Run tests and verify they pass**
133
+
134
+ ```bash
135
+ npx vitest run src/codegen/targets/kotlin/ajsc-adapter.test.ts
136
+ ```
137
+
138
+ Expected: PASS (2 tests).
139
+
140
+ - [ ] **Step 5: Commit**
141
+
142
+ ```bash
143
+ git add src/codegen/targets/kotlin/ajsc-adapter.ts src/codegen/targets/kotlin/ajsc-adapter.test.ts
144
+ git commit -m "feat(codegen/kotlin): define KotlinEmitter contract + test stub"
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Task 2: Format helpers
150
+
151
+ **Files:**
152
+ - Create: `src/codegen/targets/kotlin/format-kotlin.ts`
153
+ - Create: `src/codegen/targets/kotlin/format-kotlin.test.ts`
154
+
155
+ Pure functions for assembling Kotlin source. Kept separate so route/scope emitters can compose them without duplication.
156
+
157
+ - [ ] **Step 1: Write the failing test**
158
+
159
+ ```ts
160
+ import { describe, expect, it } from 'vitest'
161
+ import {
162
+ kotlinPackageDecl,
163
+ kotlinSourceHashHeader,
164
+ kotlinImports,
165
+ indent,
166
+ } from './format-kotlin.js'
167
+
168
+ describe('format-kotlin', () => {
169
+ it('emits a package declaration', () => {
170
+ expect(kotlinPackageDecl('com.example.api')).toBe('package com.example.api')
171
+ })
172
+
173
+ it('emits a source-hash header line', () => {
174
+ expect(kotlinSourceHashHeader('abc123')).toBe('// Source hash: abc123')
175
+ })
176
+
177
+ it('dedupes and sorts imports', () => {
178
+ expect(kotlinImports(['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.Serializable'])).toBe(
179
+ 'import kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable',
180
+ )
181
+ })
182
+
183
+ it('returns empty string when no imports', () => {
184
+ expect(kotlinImports([])).toBe('')
185
+ })
186
+
187
+ it('indents every line by 4 spaces per level', () => {
188
+ expect(indent('a\nb', 1)).toBe(' a\n b')
189
+ expect(indent('a', 2)).toBe(' a')
190
+ })
191
+
192
+ it('preserves blank lines without trailing whitespace when indenting', () => {
193
+ expect(indent('a\n\nb', 1)).toBe(' a\n\n b')
194
+ })
195
+ })
196
+ ```
197
+
198
+ - [ ] **Step 2: Run the test and verify it fails**
199
+
200
+ ```bash
201
+ npx vitest run src/codegen/targets/kotlin/format-kotlin.test.ts
202
+ ```
203
+
204
+ Expected: FAIL — module not found.
205
+
206
+ - [ ] **Step 3: Write minimal implementation**
207
+
208
+ ```ts
209
+ export function kotlinPackageDecl(pkg: string): string {
210
+ return `package ${pkg}`
211
+ }
212
+
213
+ export function kotlinSourceHashHeader(hash: string): string {
214
+ return `// Source hash: ${hash}`
215
+ }
216
+
217
+ export function kotlinImports(imports: string[]): string {
218
+ if (imports.length === 0) return ''
219
+ const unique = Array.from(new Set(imports)).sort()
220
+ return unique.map((i) => `import ${i}`).join('\n')
221
+ }
222
+
223
+ export function indent(text: string, level: number): string {
224
+ const prefix = ' '.repeat(level)
225
+ return text
226
+ .split('\n')
227
+ .map((line) => (line.length === 0 ? line : `${prefix}${line}`))
228
+ .join('\n')
229
+ }
230
+ ```
231
+
232
+ - [ ] **Step 4: Run tests and verify they pass**
233
+
234
+ ```bash
235
+ npx vitest run src/codegen/targets/kotlin/format-kotlin.test.ts
236
+ ```
237
+
238
+ Expected: PASS (6 tests).
239
+
240
+ - [ ] **Step 5: Commit**
241
+
242
+ ```bash
243
+ git add src/codegen/targets/kotlin/format-kotlin.ts src/codegen/targets/kotlin/format-kotlin.test.ts
244
+ git commit -m "feat(codegen/kotlin): add Kotlin source-formatting helpers"
245
+ ```
246
+
247
+ ---
248
+
249
+ ## Task 3: Emit per-route Kotlin block
250
+
251
+ **Files:**
252
+ - Create: `src/codegen/targets/kotlin/emit-route-kotlin.ts`
253
+ - Create: `src/codegen/targets/kotlin/emit-route-kotlin.test.ts`
254
+
255
+ Renders one `object RouteName { ... }` block. Drives the `KotlinEmitter` once per schema slot (`pathParams`, `query`, `body`, `response`) and once per error type. Stream routes are skipped with a warning (out of scope).
256
+
257
+ The output is the inner code of the route object — the scope emitter wraps it with `object RouteName { ... }`.
258
+
259
+ **Important shape note:** Per the codebase, `route.errors` is `string[]` (taxonomy keys), and the actual error schemas live in the envelope's top-level `errors: ErrorDoc[]` (`{ name, schema, ... }`). The route emitter therefore takes an `errorSchemas: Map<string, unknown>` parameter that the scope emitter (Task 4) and pipeline (Task 5) build from `envelope.errors`.
260
+
261
+ - [ ] **Step 1: Write the failing test (path-param route)**
262
+
263
+ Write `src/codegen/targets/kotlin/emit-route-kotlin.test.ts`. Start with one happy-path case:
264
+
265
+ ```ts
266
+ import { describe, expect, it } from 'vitest'
267
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
268
+ import { emitKotlinRoute } from './emit-route-kotlin.js'
269
+ import { createStubKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
270
+
271
+ const ok = (code: string, rootTypeName: string): KotlinEmitResult => ({
272
+ code,
273
+ rootTypeName,
274
+ extractedTypeNames: [],
275
+ imports: ['kotlinx.serialization.Serializable'],
276
+ })
277
+
278
+ const noErrors = new Map<string, unknown>()
279
+
280
+ describe('emitKotlinRoute', () => {
281
+ it('emits an api-kind route with path params and a response', () => {
282
+ const route: AnyHttpRouteDoc = {
283
+ kind: 'api',
284
+ name: 'GetUser',
285
+ method: 'GET',
286
+ fullPath: '/users/:id',
287
+ schema: {
288
+ input: {
289
+ pathParams: { type: 'object' },
290
+ },
291
+ returnType: { type: 'object' },
292
+ },
293
+ errors: [],
294
+ } as unknown as AnyHttpRouteDoc
295
+
296
+ const emitter = createStubKotlinEmitter({
297
+ PathParams: ok('@Serializable data class PathParams(val id: String)', 'PathParams'),
298
+ Response: ok('@Serializable data class Response(val id: String, val name: String)', 'Response'),
299
+ })
300
+
301
+ const result = emitKotlinRoute(route, emitter, noErrors)
302
+
303
+ expect(result.imports).toContain('kotlinx.serialization.Serializable')
304
+ expect(result.code).toContain('const val method = "GET"')
305
+ expect(result.code).toContain('const val pathTemplate = "/users/{id}"')
306
+ expect(result.code).toContain('fun path(p: PathParams): String = "/users/${p.id}"')
307
+ expect(result.code).toContain('@Serializable data class PathParams(val id: String)')
308
+ expect(result.code).toContain('@Serializable data class Response(val id: String, val name: String)')
309
+ })
310
+ })
311
+ ```
312
+
313
+ - [ ] **Step 2: Run the test and verify it fails**
314
+
315
+ ```bash
316
+ npx vitest run src/codegen/targets/kotlin/emit-route-kotlin.test.ts
317
+ ```
318
+
319
+ Expected: FAIL — module not found.
320
+
321
+ - [ ] **Step 3: Implement minimal `emitKotlinRoute` for api/rpc with path-param translation**
322
+
323
+ ```ts
324
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
325
+ import type { KotlinEmitter } from './ajsc-adapter.js'
326
+ import { indent } from './format-kotlin.js'
327
+
328
+ export interface EmitRouteResult {
329
+ /** Inner body of the `object RouteName { ... }` block — already indented one level. */
330
+ code: string
331
+ /** Imports collected from every ajsc emit + any helpers this route used. */
332
+ imports: string[]
333
+ /** Outer route name used as the `object RouteName` identifier. */
334
+ routeName: string
335
+ }
336
+
337
+ const COLON_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g
338
+
339
+ function toBracePath(template: string): string {
340
+ return template.replace(COLON_PARAM_RE, '{$1}')
341
+ }
342
+
343
+ function pathParamNames(template: string): string[] {
344
+ const names: string[] = []
345
+ for (const match of template.matchAll(COLON_PARAM_RE)) names.push(match[1])
346
+ return names
347
+ }
348
+
349
+ function buildPathFn(bracePath: string, params: string[]): string {
350
+ if (params.length === 0) return `const val path = "${bracePath}"`
351
+ let body = bracePath
352
+ for (const name of params) body = body.replace(`{${name}}`, `\${p.${name}}`)
353
+ return `fun path(p: PathParams): String = "${body}"`
354
+ }
355
+
356
+ export function emitKotlinRoute(
357
+ route: AnyHttpRouteDoc,
358
+ emitter: KotlinEmitter,
359
+ errorSchemas: Map<string, unknown>,
360
+ ): EmitRouteResult {
361
+ const kind = (route as { kind?: string }).kind
362
+ if (kind === 'stream') {
363
+ console.warn(`[ts-procedures-codegen] Skipping stream route "${route.name}" — streams are out of scope for kotlin target.`)
364
+ return { code: '', imports: [], routeName: route.name }
365
+ }
366
+
367
+ const isApi = kind === 'api' || 'fullPath' in route
368
+ const rawPath = isApi ? (route as { fullPath: string }).fullPath : (route as { path: string }).path
369
+ const method = String((route as { method: string }).method).toUpperCase()
370
+ const bracePath = toBracePath(rawPath)
371
+ const params = pathParamNames(rawPath)
372
+
373
+ const lines: string[] = [
374
+ `const val method = "${method}"`,
375
+ `const val pathTemplate = "${bracePath}"`,
376
+ buildPathFn(bracePath, params),
377
+ ]
378
+ const imports: string[] = []
379
+
380
+ const schema = (route as { schema?: Record<string, unknown> }).schema ?? {}
381
+ const input = (schema.input ?? {}) as Record<string, unknown>
382
+
383
+ // Per-slot emission. Order is fixed for deterministic output.
384
+ const slots: Array<{ key: string; rootName: string; source: unknown }> = [
385
+ { key: 'pathParams', rootName: 'PathParams', source: input.pathParams },
386
+ { key: 'query', rootName: 'Query', source: input.query },
387
+ { key: 'body', rootName: 'Body', source: input.body },
388
+ { key: 'response', rootName: 'Response', source: schema.returnType },
389
+ ]
390
+
391
+ for (const slot of slots) {
392
+ if (slot.source == null) continue
393
+ const result = emitter.emit(slot.source as Record<string, unknown>, { rootTypeName: slot.rootName })
394
+ lines.push('')
395
+ lines.push(result.code)
396
+ imports.push(...result.imports)
397
+ }
398
+
399
+ // Errors namespace — route.errors is `string[]` of taxonomy keys; look up each schema
400
+ // from the envelope-level errors map. Keys without schemas are skipped silently
401
+ // (matching the existing TS scope emitter's `errorKeys` filter).
402
+ const routeErrorKeys = ((route as { errors?: string[] }).errors ?? [])
403
+ .filter((key) => errorSchemas.has(key))
404
+ if (routeErrorKeys.length > 0) {
405
+ const inner: string[] = []
406
+ for (const key of routeErrorKeys) {
407
+ const r = emitter.emit(errorSchemas.get(key) as Record<string, unknown>, { rootTypeName: key })
408
+ inner.push(r.code)
409
+ imports.push(...r.imports)
410
+ }
411
+ lines.push('')
412
+ lines.push('object Errors {')
413
+ lines.push(indent(inner.join('\n\n'), 1))
414
+ lines.push('}')
415
+ }
416
+
417
+ return { code: lines.join('\n'), imports, routeName: route.name }
418
+ }
419
+ ```
420
+
421
+ - [ ] **Step 4: Run the test and verify it passes**
422
+
423
+ ```bash
424
+ npx vitest run src/codegen/targets/kotlin/emit-route-kotlin.test.ts
425
+ ```
426
+
427
+ Expected: PASS.
428
+
429
+ - [ ] **Step 5: Add tests for additional route shapes**
430
+
431
+ Append to `emit-route-kotlin.test.ts`:
432
+
433
+ ```ts
434
+ it('emits a route with no path params using a path constant', () => {
435
+ const route = {
436
+ kind: 'api',
437
+ name: 'CreateUser',
438
+ method: 'POST',
439
+ fullPath: '/users',
440
+ schema: { input: { body: { type: 'object' } }, returnType: { type: 'object' } },
441
+ errors: [],
442
+ } as unknown as AnyHttpRouteDoc
443
+
444
+ const emitter = createStubKotlinEmitter({
445
+ Body: ok('@Serializable data class Body(val name: String)', 'Body'),
446
+ Response: ok('@Serializable data class Response(val id: String)', 'Response'),
447
+ })
448
+
449
+ const result = emitKotlinRoute(route, emitter, noErrors)
450
+ expect(result.code).toContain('const val path = "/users"')
451
+ expect(result.code).not.toContain('fun path(')
452
+ })
453
+
454
+ it('emits an Errors namespace for routes whose error keys have schemas in the envelope', () => {
455
+ const route = {
456
+ kind: 'api',
457
+ name: 'GetUser',
458
+ method: 'GET',
459
+ fullPath: '/users/:id',
460
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
461
+ errors: ['NotFound'],
462
+ } as unknown as AnyHttpRouteDoc
463
+
464
+ const emitter = createStubKotlinEmitter({
465
+ PathParams: ok('@Serializable data class PathParams(val id: String)', 'PathParams'),
466
+ Response: ok('@Serializable data class Response(val id: String)', 'Response'),
467
+ NotFound: ok('@Serializable data class NotFound(val name: String, val message: String)', 'NotFound'),
468
+ })
469
+
470
+ const errorSchemas = new Map<string, unknown>([['NotFound', { type: 'object' }]])
471
+ const result = emitKotlinRoute(route, emitter, errorSchemas)
472
+ expect(result.code).toContain('object Errors {')
473
+ expect(result.code).toContain('@Serializable data class NotFound(val name: String, val message: String)')
474
+ })
475
+
476
+ it('silently skips error keys with no schema in the envelope map', () => {
477
+ const route = {
478
+ kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users',
479
+ schema: {}, errors: ['UnknownTaxonomyKey'],
480
+ } as unknown as AnyHttpRouteDoc
481
+ const result = emitKotlinRoute(route, createStubKotlinEmitter({}), new Map())
482
+ expect(result.code).not.toContain('object Errors {')
483
+ })
484
+
485
+ it('skips stream routes with a warning', () => {
486
+ const route = { kind: 'stream', name: 'WatchUsers', method: 'GET', path: '/users/stream', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
487
+ const result = emitKotlinRoute(route, createStubKotlinEmitter({}), noErrors)
488
+ expect(result.code).toBe('')
489
+ })
490
+ ```
491
+
492
+ - [ ] **Step 6: Run all tests and verify they pass**
493
+
494
+ ```bash
495
+ npx vitest run src/codegen/targets/kotlin/emit-route-kotlin.test.ts
496
+ ```
497
+
498
+ Expected: PASS (4 tests).
499
+
500
+ - [ ] **Step 7: Commit**
501
+
502
+ ```bash
503
+ git add src/codegen/targets/kotlin/emit-route-kotlin.ts src/codegen/targets/kotlin/emit-route-kotlin.test.ts
504
+ git commit -m "feat(codegen/kotlin): emit per-route object block with method, path, types, errors"
505
+ ```
506
+
507
+ ---
508
+
509
+ ## Task 4: Emit per-scope Kotlin file
510
+
511
+ **Files:**
512
+ - Create: `src/codegen/targets/kotlin/emit-scope-kotlin.ts`
513
+ - Create: `src/codegen/targets/kotlin/emit-scope-kotlin.test.ts`
514
+
515
+ Renders one `.kt` file per scope. Composes route emissions inside an outer `object Scope { ... }`, prepends package + imports + source-hash header.
516
+
517
+ - [ ] **Step 1: Write the failing test**
518
+
519
+ ```ts
520
+ import { describe, expect, it } from 'vitest'
521
+ import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
522
+ import type { ScopeGroup } from '../../group-routes.js'
523
+ import { emitKotlinScope } from './emit-scope-kotlin.js'
524
+ import { createStubKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
525
+
526
+ const ok = (code: string, rootTypeName: string): KotlinEmitResult => ({
527
+ code,
528
+ rootTypeName,
529
+ extractedTypeNames: [],
530
+ imports: ['kotlinx.serialization.Serializable'],
531
+ })
532
+
533
+ describe('emitKotlinScope', () => {
534
+ it('produces a complete kotlin source file for a single-route scope', () => {
535
+ const route: AnyHttpRouteDoc = {
536
+ kind: 'api',
537
+ name: 'GetUser',
538
+ method: 'GET',
539
+ fullPath: '/users/:id',
540
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
541
+ errors: [],
542
+ } as unknown as AnyHttpRouteDoc
543
+
544
+ const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route] }
545
+ const emitter = createStubKotlinEmitter({
546
+ PathParams: ok('@Serializable data class PathParams(val id: String)', 'PathParams'),
547
+ Response: ok('@Serializable data class Response(val id: String)', 'Response'),
548
+ })
549
+
550
+ const file = emitKotlinScope(group, { kotlinPackage: 'com.example.api', sourceHash: 'abc123' }, emitter, new Map())
551
+
552
+ expect(file.filename).toBe('Users.kt')
553
+ expect(file.code).toContain('package com.example.api')
554
+ expect(file.code).toContain('// Source hash: abc123')
555
+ expect(file.code).toContain('import kotlinx.serialization.Serializable')
556
+ expect(file.code).toContain('object Users {')
557
+ expect(file.code).toContain('object GetUser {')
558
+ expect(file.code).toContain('const val method = "GET"')
559
+ })
560
+
561
+ it('joins multiple routes inside one scope object', () => {
562
+ const route1 = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users/:id', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
563
+ const route2 = { kind: 'api', name: 'CreateUser', method: 'POST', fullPath: '/users', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
564
+ const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route1, route2] }
565
+ const emitter = createStubKotlinEmitter({})
566
+
567
+ const file = emitKotlinScope(group, { kotlinPackage: 'com.example.api', sourceHash: 'h' }, emitter, new Map())
568
+ expect(file.code).toContain('object GetUser {')
569
+ expect(file.code).toContain('object CreateUser {')
570
+ // exactly one outer scope object
571
+ expect((file.code.match(/^object Users \{/gm) ?? []).length).toBe(1)
572
+ })
573
+
574
+ it('uses PascalCase scope name for the filename and outer object', () => {
575
+ const group: ScopeGroup = { scopeKey: 'admin-users', camelCase: 'adminUsers', routes: [] }
576
+ const file = emitKotlinScope(group, { kotlinPackage: 'p', sourceHash: 'h' }, createStubKotlinEmitter({}), new Map())
577
+ expect(file.filename).toBe('AdminUsers.kt')
578
+ expect(file.code).toContain('object AdminUsers {')
579
+ })
580
+ })
581
+ ```
582
+
583
+ - [ ] **Step 2: Run the test and verify it fails**
584
+
585
+ ```bash
586
+ npx vitest run src/codegen/targets/kotlin/emit-scope-kotlin.test.ts
587
+ ```
588
+
589
+ Expected: FAIL.
590
+
591
+ - [ ] **Step 3: Implement `emitKotlinScope`**
592
+
593
+ ```ts
594
+ import type { ScopeGroup } from '../../group-routes.js'
595
+ import type { KotlinEmitter } from './ajsc-adapter.js'
596
+ import { emitKotlinRoute } from './emit-route-kotlin.js'
597
+ import { kotlinPackageDecl, kotlinSourceHashHeader, kotlinImports, indent } from './format-kotlin.js'
598
+
599
+ export interface EmitScopeOptions {
600
+ kotlinPackage: string
601
+ sourceHash: string
602
+ }
603
+
604
+ export interface EmittedKotlinFile {
605
+ filename: string
606
+ code: string
607
+ }
608
+
609
+ function pascalCase(scope: string): string {
610
+ return scope
611
+ .split('-')
612
+ .filter((p) => p.length > 0)
613
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
614
+ .join('')
615
+ }
616
+
617
+ export function emitKotlinScope(
618
+ group: ScopeGroup,
619
+ opts: EmitScopeOptions,
620
+ emitter: KotlinEmitter,
621
+ errorSchemas: Map<string, unknown>,
622
+ ): EmittedKotlinFile {
623
+ const scopeName = pascalCase(group.scopeKey)
624
+ const allImports: string[] = []
625
+ const routeBlocks: string[] = []
626
+
627
+ for (const route of group.routes) {
628
+ const r = emitKotlinRoute(route, emitter, errorSchemas)
629
+ if (r.code === '') continue
630
+ allImports.push(...r.imports)
631
+ const wrapped = `object ${r.routeName} {\n${indent(r.code, 1)}\n}`
632
+ routeBlocks.push(wrapped)
633
+ }
634
+
635
+ const innerScope = routeBlocks.length === 0 ? '' : indent(routeBlocks.join('\n\n'), 1)
636
+ const scopeBlock = innerScope === ''
637
+ ? `object ${scopeName} {\n}`
638
+ : `object ${scopeName} {\n${innerScope}\n}`
639
+
640
+ const importsBlock = kotlinImports(allImports)
641
+ const parts = [
642
+ kotlinPackageDecl(opts.kotlinPackage),
643
+ kotlinSourceHashHeader(opts.sourceHash),
644
+ importsBlock,
645
+ scopeBlock,
646
+ ].filter((p) => p.length > 0)
647
+
648
+ return { filename: `${scopeName}.kt`, code: parts.join('\n\n') + '\n' }
649
+ }
650
+ ```
651
+
652
+ - [ ] **Step 4: Run tests and verify they pass**
653
+
654
+ ```bash
655
+ npx vitest run src/codegen/targets/kotlin/emit-scope-kotlin.test.ts
656
+ ```
657
+
658
+ Expected: PASS (3 tests).
659
+
660
+ - [ ] **Step 5: Commit**
661
+
662
+ ```bash
663
+ git add src/codegen/targets/kotlin/emit-scope-kotlin.ts src/codegen/targets/kotlin/emit-scope-kotlin.test.ts
664
+ git commit -m "feat(codegen/kotlin): emit per-scope .kt file with package, imports, and outer object"
665
+ ```
666
+
667
+ ---
668
+
669
+ ## Task 5: Pipeline target dispatch
670
+
671
+ **Files:**
672
+ - Modify: `src/codegen/pipeline.ts`
673
+ - Modify: `src/codegen/pipeline.test.ts` (extend) or create dedicated kotlin pipeline test
674
+ - Modify: `src/codegen/index.ts` (surface new options)
675
+
676
+ When `target === 'kotlin'`, the pipeline must skip TS-only outputs (`_errors.ts`, `index.ts`, `_types.ts`, `_client.ts`) and instead emit only Kotlin `.kt` files.
677
+
678
+ - [ ] **Step 1: Write the failing test**
679
+
680
+ Append to `src/codegen/pipeline.test.ts`:
681
+
682
+ ```ts
683
+ import { runPipeline } from './pipeline.js'
684
+ import { createStubKotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
685
+
686
+ describe('runPipeline (kotlin target)', () => {
687
+ it('emits only .kt files when target is "kotlin"', async () => {
688
+ const envelope = {
689
+ version: '1' as const,
690
+ routes: [
691
+ {
692
+ kind: 'api',
693
+ name: 'GetUser',
694
+ scope: 'users',
695
+ method: 'GET',
696
+ fullPath: '/users/:id',
697
+ schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
698
+ errors: [],
699
+ },
700
+ ],
701
+ errors: [],
702
+ } as any
703
+
704
+ const files = await runPipeline({
705
+ envelope,
706
+ outDir: 'out',
707
+ dryRun: true,
708
+ target: 'kotlin',
709
+ kotlinPackage: 'com.example.api',
710
+ kotlinEmitter: createStubKotlinEmitter({
711
+ PathParams: { code: '@Serializable data class PathParams(val id: String)', rootTypeName: 'PathParams', extractedTypeNames: [], imports: ['kotlinx.serialization.Serializable'] },
712
+ Response: { code: '@Serializable data class Response(val id: String)', rootTypeName: 'Response', extractedTypeNames: [], imports: ['kotlinx.serialization.Serializable'] },
713
+ }),
714
+ })
715
+
716
+ expect(files.map((f) => f.path)).toEqual(['out/Users.kt'])
717
+ expect(files[0].code).toContain('object Users {')
718
+ })
719
+
720
+ it('does not emit _errors.ts, index.ts, or client runtime files when target is "kotlin"', async () => {
721
+ const envelope = { version: '1', routes: [], errors: [] } as any
722
+ const files = await runPipeline({
723
+ envelope, outDir: 'out', dryRun: true,
724
+ target: 'kotlin', kotlinPackage: 'p',
725
+ kotlinEmitter: createStubKotlinEmitter({}),
726
+ })
727
+ expect(files.find((f) => f.path.endsWith('_errors.ts'))).toBeUndefined()
728
+ expect(files.find((f) => f.path.endsWith('index.ts'))).toBeUndefined()
729
+ expect(files.find((f) => f.path.endsWith('_client.ts'))).toBeUndefined()
730
+ })
731
+ })
732
+ ```
733
+
734
+ - [ ] **Step 2: Run the test and verify it fails**
735
+
736
+ ```bash
737
+ npx vitest run src/codegen/pipeline.test.ts
738
+ ```
739
+
740
+ Expected: FAIL — `target` and `kotlinPackage` not on `PipelineOptions`.
741
+
742
+ - [ ] **Step 3: Extend `PipelineOptions` and dispatch on target**
743
+
744
+ Edit `src/codegen/pipeline.ts`. Add to `PipelineOptions`:
745
+
746
+ ```ts
747
+ import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
748
+ import { emitKotlinScope } from './targets/kotlin/emit-scope-kotlin.js'
749
+
750
+ export interface PipelineOptions {
751
+ // ... existing fields ...
752
+ target?: 'ts' | 'kotlin'
753
+ kotlinPackage?: string
754
+ /** Injected for tests; production wiring resolves a real ajsc emitter. */
755
+ kotlinEmitter?: KotlinEmitter
756
+ }
757
+ ```
758
+
759
+ Inside `runPipeline`, before the existing TS scope-emit loop, branch on target:
760
+
761
+ ```ts
762
+ if (options.target === 'kotlin') {
763
+ if (options.kotlinPackage == null) {
764
+ throw new Error('[ts-procedures-codegen] target=kotlin requires kotlinPackage')
765
+ }
766
+ if (options.kotlinEmitter == null) {
767
+ throw new Error('[ts-procedures-codegen] target=kotlin requires a kotlinEmitter (production wiring pending — Task 7)')
768
+ }
769
+
770
+ // Route errors are taxonomy keys (string[]); look up actual schemas from the
771
+ // envelope's top-level errors array. Mirrors the existing TS scope emitter.
772
+ const errorSchemas = new Map<string, unknown>()
773
+ for (const e of envelope.errors) {
774
+ if (e.schema != null) errorSchemas.set(e.name, e.schema)
775
+ }
776
+
777
+ const files: GeneratedFile[] = []
778
+ for (const group of groupArray) {
779
+ const emitted = emitKotlinScope(
780
+ group,
781
+ { kotlinPackage: options.kotlinPackage, sourceHash: hash },
782
+ options.kotlinEmitter,
783
+ errorSchemas,
784
+ )
785
+ files.push({ path: join(outDir, emitted.filename), code: emitted.code })
786
+ }
787
+
788
+ if (dryRun) {
789
+ for (const f of files) {
790
+ console.log(`[dry-run] Would write: ${f.path} (${Buffer.byteLength(f.code, 'utf8')} bytes)`)
791
+ }
792
+ } else {
793
+ if (cleanOutDir) await rm(outDir, { recursive: true, force: true })
794
+ await mkdir(outDir, { recursive: true })
795
+ await Promise.all(files.map((f) => writeFile(f.path, f.code, 'utf8')))
796
+ }
797
+ return files
798
+ }
799
+ ```
800
+
801
+ (All existing TS code remains below, untouched.)
802
+
803
+ Also update `src/codegen/index.ts` to surface `target`, `kotlinPackage`, `kotlinEmitter` on `GenerateClientOptions`, and pass them through to `runPipeline`.
804
+
805
+ - [ ] **Step 4: Run tests and verify they pass**
806
+
807
+ ```bash
808
+ npx vitest run src/codegen/pipeline.test.ts
809
+ ```
810
+
811
+ Expected: PASS.
812
+
813
+ - [ ] **Step 5: Run full codegen test suite to verify TS path is unaffected**
814
+
815
+ ```bash
816
+ npx vitest run src/codegen/
817
+ ```
818
+
819
+ Expected: PASS — all existing TS-target tests still green.
820
+
821
+ - [ ] **Step 6: Commit**
822
+
823
+ ```bash
824
+ git add src/codegen/pipeline.ts src/codegen/pipeline.test.ts src/codegen/index.ts
825
+ git commit -m "feat(codegen): dispatch on target to emit kotlin .kt files"
826
+ ```
827
+
828
+ ---
829
+
830
+ ## Task 6: CLI flags for kotlin target
831
+
832
+ **Files:**
833
+ - Modify: `src/codegen/bin/cli.ts`
834
+ - Modify: `src/codegen/bin/cli.test.ts`
835
+
836
+ Adds `--target` and `--kotlin-package` flags. Extends `CodegenConfig` for the `kotlin` config-file section. Validates that kotlin requires a package via either CLI or config.
837
+
838
+ - [ ] **Step 1: Write the failing test**
839
+
840
+ Append to `src/codegen/bin/cli.test.ts`. The existing CLI uses a single `parseArgs(argv, config?)` signature — pass an empty argv with a config to test config-only resolution.
841
+
842
+ ```ts
843
+ describe('cli — kotlin target', () => {
844
+ it('parses --target kotlin and --kotlin-package from CLI flags', () => {
845
+ const args = parseArgs(
846
+ ['--target', 'kotlin', '--kotlin-package', 'com.example.api', '--out', 'out', '--file', 'env.json'],
847
+ )
848
+ expect(args.target).toBe('kotlin')
849
+ expect(args.kotlin?.package).toBe('com.example.api')
850
+ })
851
+
852
+ it('reads kotlin.package from config when no CLI flag is provided', () => {
853
+ const config: CodegenConfig = {
854
+ target: 'kotlin',
855
+ kotlin: { package: 'com.example.api' },
856
+ outDir: 'out',
857
+ file: 'env.json',
858
+ }
859
+ const args = parseArgs(['--out', 'out', '--file', 'env.json'], config)
860
+ expect(args.target).toBe('kotlin')
861
+ expect(args.kotlin?.package).toBe('com.example.api')
862
+ })
863
+
864
+ it('CLI flag overrides config value', () => {
865
+ const config: CodegenConfig = {
866
+ target: 'kotlin',
867
+ kotlin: { package: 'old.pkg' },
868
+ outDir: 'out',
869
+ file: 'env.json',
870
+ }
871
+ const args = parseArgs(['--kotlin-package', 'new.pkg', '--out', 'out', '--file', 'env.json'], config)
872
+ expect(args.kotlin?.package).toBe('new.pkg')
873
+ })
874
+
875
+ it('errors when target is kotlin and no package is provided', () => {
876
+ expect(() =>
877
+ parseArgs(['--target', 'kotlin', '--out', 'out', '--file', 'env.json']),
878
+ ).toThrow(/--kotlin-package/)
879
+ })
880
+
881
+ it('--target ts is the default', () => {
882
+ const args = parseArgs(['--out', 'out', '--file', 'env.json'])
883
+ expect(args.target ?? 'ts').toBe('ts')
884
+ })
885
+ })
886
+ ```
887
+
888
+ (If `parseArgs` is not currently exported from `cli.ts`, export it. The implementer should mirror existing test style in `cli.test.ts` — there is no separate `mergeConfigAndArgs` helper.)
889
+
890
+ - [ ] **Step 2: Run the test and verify it fails**
891
+
892
+ ```bash
893
+ npx vitest run src/codegen/bin/cli.test.ts
894
+ ```
895
+
896
+ Expected: FAIL.
897
+
898
+ - [ ] **Step 3: Extend `CodegenConfig` / `ParsedArgs` and add flag parsing**
899
+
900
+ In `cli.ts`, add to both interfaces:
901
+
902
+ ```ts
903
+ target?: 'ts' | 'kotlin'
904
+ kotlin?: { package: string }
905
+ ```
906
+
907
+ Add flag parsing for `--target <ts|kotlin>` and `--kotlin-package <pkg>`. Add validation: when `target === 'kotlin'`, require either CLI flag or `config.kotlin.package`; otherwise throw `Error('[ts-procedures-codegen] target=kotlin requires --kotlin-package or kotlin.package in config')`.
908
+
909
+ In the existing `--service-name` handling, log a debug-level note when the target is Kotlin (the value is silently ignored per spec); no behavior change.
910
+
911
+ - [ ] **Step 4: Run all CLI tests and verify they pass**
912
+
913
+ ```bash
914
+ npx vitest run src/codegen/bin/cli.test.ts
915
+ ```
916
+
917
+ Expected: PASS.
918
+
919
+ - [ ] **Step 5: Commit**
920
+
921
+ ```bash
922
+ git add src/codegen/bin/cli.ts src/codegen/bin/cli.test.ts
923
+ git commit -m "feat(codegen/cli): add --target and --kotlin-package flags"
924
+ ```
925
+
926
+ ---
927
+
928
+ ## Task 7: Wire production ajsc emitter
929
+
930
+ **Files:**
931
+ - Modify: `src/codegen/targets/kotlin/ajsc-adapter.ts`
932
+ - Modify: `src/codegen/bin/cli.ts` (resolve the production emitter when `target === 'kotlin'`)
933
+
934
+ So far the pipeline requires the caller to inject a `KotlinEmitter`. This task wires the production path that imports `ajsc` and adapts its return shape to `KotlinEmitResult`.
935
+
936
+ **ajsc dependency:** This task depends on `ajsc` exposing `emitKotlin`. If ajsc Phase A is not yet delivered, complete steps 1–4 with a clear `TODO(ajsc-phase-a)` marker; the import will throw a clear error at runtime until ajsc ships. The unit-test path continues to use the stub.
937
+
938
+ - [ ] **Step 1: Add a graceful resolver**
939
+
940
+ Append to `ajsc-adapter.ts`:
941
+
942
+ ```ts
943
+ /**
944
+ * Resolves the production Kotlin emitter from `ajsc`. Throws a clear error
945
+ * if the ajsc package does not yet expose `emitKotlin` (Phase A pending).
946
+ */
947
+ export async function resolveProductionKotlinEmitter(): Promise<KotlinEmitter> {
948
+ // TODO(ajsc-phase-a): replace dynamic import with a static import once ajsc ships.
949
+ const ajsc = await import('ajsc').catch(() => null)
950
+ const emitKotlin = (ajsc as { emitKotlin?: unknown } | null)?.emitKotlin
951
+ if (typeof emitKotlin !== 'function') {
952
+ throw new Error(
953
+ '[ts-procedures-codegen] ajsc.emitKotlin is not available. ' +
954
+ 'Kotlin codegen requires ajsc Phase A. See docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md.',
955
+ )
956
+ }
957
+ return {
958
+ emit(schema, opts) {
959
+ // ajsc's return shape is normalized to KotlinEmitResult here.
960
+ const r = (emitKotlin as (s: unknown, o: unknown) => KotlinEmitResult)(schema, opts)
961
+ return r
962
+ },
963
+ }
964
+ }
965
+ ```
966
+
967
+ - [ ] **Step 2: Test the resolver fails clearly when ajsc.emitKotlin is missing**
968
+
969
+ In `ajsc-adapter.test.ts`:
970
+
971
+ ```ts
972
+ it('throws a clear error when ajsc.emitKotlin is unavailable', async () => {
973
+ // We can't easily mock the dynamic import; this test serves as documentation.
974
+ // On a system where ajsc has no emitKotlin export, this will throw.
975
+ // Skipped by default; flip to .only locally to verify message wording.
976
+ expect.assertions(0)
977
+ })
978
+ ```
979
+
980
+ - [ ] **Step 3: Wire CLI to pass the resolved emitter into the pipeline**
981
+
982
+ In `cli.ts` where `runPipeline` / `generateClient` is invoked, when `target === 'kotlin'`, await `resolveProductionKotlinEmitter()` and pass it as `kotlinEmitter`.
983
+
984
+ - [ ] **Step 4: Run all codegen tests**
985
+
986
+ ```bash
987
+ npx vitest run src/codegen/
988
+ ```
989
+
990
+ Expected: PASS — TS path unchanged; kotlin unit tests still pass via stub injection.
991
+
992
+ - [ ] **Step 5: Commit**
993
+
994
+ ```bash
995
+ git add src/codegen/targets/kotlin/ajsc-adapter.ts src/codegen/bin/cli.ts src/codegen/targets/kotlin/ajsc-adapter.test.ts
996
+ git commit -m "feat(codegen/kotlin): resolve production ajsc.emitKotlin (gated on ajsc phase A)"
997
+ ```
998
+
999
+ ---
1000
+
1001
+ ## Task 8: Integration test with fixture envelope and golden output
1002
+
1003
+ **Files:**
1004
+ - Create: `src/codegen/targets/kotlin/__fixtures__/users-envelope.json`
1005
+ - Create: `src/codegen/targets/kotlin/__fixtures__/users-golden.kt`
1006
+ - Create: `src/codegen/targets/kotlin/integration.test.ts`
1007
+
1008
+ Asserts the entire pipeline produces byte-identical output for a representative envelope. Uses a stub emitter with hand-crafted realistic ajsc-style outputs.
1009
+
1010
+ - [ ] **Step 1: Create the fixture envelope**
1011
+
1012
+ Write `src/codegen/targets/kotlin/__fixtures__/users-envelope.json` with one scope (`users`) containing two routes:
1013
+ - `GetUser` (GET `/users/:id`, path params + response, error `NotFound`)
1014
+ - `CreateUser` (POST `/users`, body + response)
1015
+
1016
+ Route-level `errors` is `string[]` of taxonomy keys; actual error schemas live in the envelope's top-level `errors` array. Match that shape:
1017
+
1018
+ ```json
1019
+ {
1020
+ "version": "1",
1021
+ "routes": [
1022
+ {
1023
+ "kind": "api",
1024
+ "name": "GetUser",
1025
+ "scope": "users",
1026
+ "method": "GET",
1027
+ "fullPath": "/users/:id",
1028
+ "schema": {
1029
+ "input": { "pathParams": { "type": "object" } },
1030
+ "returnType": { "type": "object" }
1031
+ },
1032
+ "errors": ["NotFound"]
1033
+ },
1034
+ {
1035
+ "kind": "api",
1036
+ "name": "CreateUser",
1037
+ "scope": "users",
1038
+ "method": "POST",
1039
+ "fullPath": "/users",
1040
+ "schema": {
1041
+ "input": { "body": { "type": "object" } },
1042
+ "returnType": { "type": "object" }
1043
+ },
1044
+ "errors": []
1045
+ }
1046
+ ],
1047
+ "errors": [
1048
+ { "name": "NotFound", "schema": { "type": "object" }, "statusCode": 404 }
1049
+ ]
1050
+ }
1051
+ ```
1052
+
1053
+ - [ ] **Step 2: Create the golden Kotlin output**
1054
+
1055
+ Write `src/codegen/targets/kotlin/__fixtures__/users-golden.kt`. Use a realistic but stub-driven shape that matches what the test will produce. The exact contents will need to align with the source-hash of the envelope (computed at test time — see Step 4); for now write the golden file *without* the source-hash line, and the test will splice it.
1056
+
1057
+ (The golden file's contents are deterministic given the stub emitter map. Hand-author it to match what `emitKotlinScope` produces for the fixture envelope; iterate after the first test run if needed.)
1058
+
1059
+ - [ ] **Step 3: Write the integration test**
1060
+
1061
+ Write `src/codegen/targets/kotlin/integration.test.ts`. Note: this package is `"type": "module"` so `__dirname` is not available — use the existing `fileURLToPath(import.meta.url)` pattern (see `src/codegen/emit-client-types.ts:14-15` for reference).
1062
+
1063
+ ```ts
1064
+ import { describe, expect, it } from 'vitest'
1065
+ import { readFile } from 'node:fs/promises'
1066
+ import { dirname, join } from 'node:path'
1067
+ import { fileURLToPath } from 'node:url'
1068
+ import { runPipeline } from '../../pipeline.js'
1069
+ import { createStubKotlinEmitter } from './ajsc-adapter.js'
1070
+
1071
+ const __filename = fileURLToPath(import.meta.url)
1072
+ const __dirname = dirname(__filename)
1073
+
1074
+ describe('kotlin codegen — integration', () => {
1075
+ it('produces byte-identical output against the golden fixture', async () => {
1076
+ const envelopePath = join(__dirname, '__fixtures__/users-envelope.json')
1077
+ const goldenPath = join(__dirname, '__fixtures__/users-golden.kt')
1078
+ const envelope = JSON.parse(await readFile(envelopePath, 'utf8'))
1079
+ const goldenTemplate = await readFile(goldenPath, 'utf8')
1080
+
1081
+ const emitter = createStubKotlinEmitter({
1082
+ PathParams: { code: '@Serializable\ndata class PathParams(val id: String)', rootTypeName: 'PathParams', extractedTypeNames: [], imports: ['kotlinx.serialization.Serializable'] },
1083
+ Response: { code: '@Serializable\ndata class Response(val id: String, val name: String)', rootTypeName: 'Response', extractedTypeNames: [], imports: ['kotlinx.serialization.Serializable'] },
1084
+ Body: { code: '@Serializable\ndata class Body(val name: String, val email: String)', rootTypeName: 'Body', extractedTypeNames: [], imports: ['kotlinx.serialization.Serializable'] },
1085
+ NotFound: { code: '@Serializable\ndata class NotFound(val name: String = "NotFound", val message: String)', rootTypeName: 'NotFound', extractedTypeNames: [], imports: ['kotlinx.serialization.Serializable'] },
1086
+ })
1087
+
1088
+ const files = await runPipeline({
1089
+ envelope, outDir: 'out', dryRun: true,
1090
+ target: 'kotlin', kotlinPackage: 'com.example.api',
1091
+ kotlinEmitter: emitter,
1092
+ })
1093
+
1094
+ expect(files).toHaveLength(1)
1095
+ expect(files[0].path).toBe('out/Users.kt')
1096
+
1097
+ // Hash is deterministic given envelope contents; splice into golden template.
1098
+ const sourceHashLine = files[0].code.split('\n').find((l) => l.startsWith('// Source hash:')) ?? ''
1099
+ const goldenWithHash = goldenTemplate.replace('// Source hash: <PLACEHOLDER>', sourceHashLine)
1100
+ expect(files[0].code).toBe(goldenWithHash)
1101
+ })
1102
+ })
1103
+ ```
1104
+
1105
+ - [ ] **Step 4: Run the test, capture actual output, finalize the golden file**
1106
+
1107
+ ```bash
1108
+ npx vitest run src/codegen/targets/kotlin/integration.test.ts
1109
+ ```
1110
+
1111
+ Expected on first run: FAIL — diff between produced output and golden. Inspect the failure, write the golden file using the produced output (with `// Source hash: <PLACEHOLDER>` swapped in for the real hash line). Re-run.
1112
+
1113
+ - [ ] **Step 5: Re-run until PASS**
1114
+
1115
+ ```bash
1116
+ npx vitest run src/codegen/targets/kotlin/integration.test.ts
1117
+ ```
1118
+
1119
+ Expected: PASS.
1120
+
1121
+ - [ ] **Step 6: Commit**
1122
+
1123
+ ```bash
1124
+ git add src/codegen/targets/kotlin/__fixtures__ src/codegen/targets/kotlin/integration.test.ts
1125
+ git commit -m "test(codegen/kotlin): integration test with fixture envelope and golden output"
1126
+ ```
1127
+
1128
+ ---
1129
+
1130
+ ## Task 9: kotlinc E2E compile test (gated)
1131
+
1132
+ **Files:**
1133
+ - Create: `src/codegen/targets/kotlin/e2e-compile.test.ts`
1134
+
1135
+ Generates real Kotlin output and runs `kotlinc` to verify the source compiles. Skipped unless `kotlinc` is available *and* ajsc Phase A is delivered.
1136
+
1137
+ **Prerequisites:** ajsc package exposes `emitKotlin`; `kotlinc` is on `PATH`. The test uses `vitest`'s `it.skipIf` to gracefully skip when these aren't met.
1138
+
1139
+ - [ ] **Step 1: Write the gated test**
1140
+
1141
+ ```ts
1142
+ import { describe, it, expect } from 'vitest'
1143
+ import { execSync } from 'node:child_process'
1144
+ import { mkdtempSync, writeFileSync } from 'node:fs'
1145
+ import { readFile } from 'node:fs/promises'
1146
+ import { tmpdir } from 'node:os'
1147
+ import { dirname, join } from 'node:path'
1148
+ import { fileURLToPath } from 'node:url'
1149
+ import { runPipeline } from '../../pipeline.js'
1150
+ import { resolveProductionKotlinEmitter } from './ajsc-adapter.js'
1151
+
1152
+ const __filename = fileURLToPath(import.meta.url)
1153
+ const __dirname = dirname(__filename)
1154
+
1155
+ function kotlincAvailable(): boolean {
1156
+ try {
1157
+ execSync('kotlinc -version', { stdio: 'ignore' })
1158
+ return true
1159
+ } catch {
1160
+ return false
1161
+ }
1162
+ }
1163
+
1164
+ describe('kotlin codegen — kotlinc compile (gated)', () => {
1165
+ it.skipIf(!kotlincAvailable() || process.env.TS_PROCEDURES_KOTLIN_E2E !== '1')(
1166
+ 'compiles generated output without errors',
1167
+ async () => {
1168
+ const emitter = await resolveProductionKotlinEmitter()
1169
+ const envelope = JSON.parse(
1170
+ await readFile(join(__dirname, '__fixtures__/users-envelope.json'), 'utf8'),
1171
+ )
1172
+ const files = await runPipeline({
1173
+ envelope, outDir: 'out', dryRun: true,
1174
+ target: 'kotlin', kotlinPackage: 'com.example.api',
1175
+ kotlinEmitter: emitter,
1176
+ })
1177
+ const dir = mkdtempSync(join(tmpdir(), 'tsp-kotlin-e2e-'))
1178
+ for (const f of files) {
1179
+ writeFileSync(join(dir, f.path.split('/').pop()!), f.code)
1180
+ }
1181
+ // Compile against the kotlinx-serialization runtime jar present in the test env.
1182
+ // If the jar isn't on the classpath, this fails; CI must provide it.
1183
+ execSync(`kotlinc ${dir}/*.kt -d ${dir}/out.jar`, { stdio: 'inherit' })
1184
+ expect(true).toBe(true) // reaching here means compile succeeded
1185
+ },
1186
+ )
1187
+ })
1188
+ ```
1189
+
1190
+ - [ ] **Step 2: Run locally to verify the gating works (skip path)**
1191
+
1192
+ ```bash
1193
+ npx vitest run src/codegen/targets/kotlin/e2e-compile.test.ts
1194
+ ```
1195
+
1196
+ Expected: SKIPPED (with reason) when `TS_PROCEDURES_KOTLIN_E2E !== '1'` or `kotlinc` is unavailable.
1197
+
1198
+ - [ ] **Step 3: Commit**
1199
+
1200
+ ```bash
1201
+ git add src/codegen/targets/kotlin/e2e-compile.test.ts
1202
+ git commit -m "test(codegen/kotlin): gated kotlinc compile e2e (requires ajsc phase A + kotlinc)"
1203
+ ```
1204
+
1205
+ ---
1206
+
1207
+ ## Task 10: Documentation
1208
+
1209
+ **Files:**
1210
+ - Modify: `CLAUDE.md` (codegen section)
1211
+ - Modify: `README.md` (if present, note the new `--target kotlin` flag)
1212
+
1213
+ - [ ] **Step 1: Update CLAUDE.md**
1214
+
1215
+ In the "Client Code Generation" section of `CLAUDE.md`, add:
1216
+
1217
+ ```markdown
1218
+ - **Kotlin target** (`--target kotlin`): emits one `.kt` file per scope with nested `object` namespaces (`Users.GetUser.Response`), HTTP method constants, path templates, and path-builder functions. Types are emitted by `ajsc.emitKotlin` with `kotlinx.serialization` annotations. **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).
1219
+ - Streams, hooks, per-call options, and error dispatch logic are deliberately out of scope for the Kotlin target. See `docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md`.
1220
+ ```
1221
+
1222
+ - [ ] **Step 2: Verify build still passes**
1223
+
1224
+ ```bash
1225
+ npm run build
1226
+ npm run test
1227
+ npm run lint
1228
+ ```
1229
+
1230
+ Expected: all green.
1231
+
1232
+ - [ ] **Step 3: Commit**
1233
+
1234
+ ```bash
1235
+ git add CLAUDE.md README.md
1236
+ git commit -m "docs: document kotlin codegen target"
1237
+ ```
1238
+
1239
+ ---
1240
+
1241
+ ## Sequencing & dependencies
1242
+
1243
+ | Task | Depends on | Blocks |
1244
+ |---|---|---|
1245
+ | 1. KotlinEmitter contract | — | 3, 4, 5, 7, 8, 9 |
1246
+ | 2. Format helpers | — | 4, 5 |
1247
+ | 3. Per-route emitter | 1, 2 | 4, 8 |
1248
+ | 4. Per-scope emitter | 1, 2, 3 | 5, 8 |
1249
+ | 5. Pipeline dispatch | 1, 4 | 6, 8 |
1250
+ | 6. CLI flags | 5 | 7 |
1251
+ | 7. Production ajsc wiring | 1, 5, 6 | 9 |
1252
+ | 8. Integration test | 4, 5 | — |
1253
+ | 9. kotlinc E2E (gated) | 7 + ajsc Phase A | — |
1254
+ | 10. Docs | all | — |
1255
+
1256
+ Tasks 1, 2, 3, 4, 5, 6, 7, 8, 10 can be executed sequentially in a single session and are fully testable without external dependencies (stubbed emitter). Task 9 is gated; mark as deferred and revisit after ajsc Phase A delivers.
1257
+
1258
+ ## Definition of done
1259
+
1260
+ - All tests in `src/codegen/targets/kotlin/` pass via `npx vitest run src/codegen/targets/kotlin/`.
1261
+ - All existing tests in `src/codegen/` still pass — TS-target behavior is unchanged.
1262
+ - `npm run build` succeeds.
1263
+ - `npm run lint` succeeds.
1264
+ - A manual run of `npx ts-procedures-codegen --target kotlin --kotlin-package com.example.api --file src/codegen/targets/kotlin/__fixtures__/users-envelope.json --out /tmp/kotlin-out` produces a `Users.kt` file matching the integration golden (modulo source-hash).
1265
+ - The kotlinc E2E test (Task 9) is staged for activation when ajsc Phase A delivers; documented in the commit and in CLAUDE.md.