ts-procedures 6.0.0 → 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.
- package/agent_config/claude-code/agents/ts-procedures-architect.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +2 -2
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +15 -27
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +11 -4
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +2 -2
- package/agent_config/copilot/copilot-instructions.md +3 -2
- package/agent_config/cursor/cursorrules +3 -2
- package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +24 -0
- package/build/codegen/targets/kotlin/ajsc-adapter.js +33 -0
- package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -0
- package/build/codegen/targets/kotlin/ajsc-adapter.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js +19 -0
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -0
- package/build/codegen/targets/kotlin/e2e-compile.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/e2e-compile.test.js +43 -0
- package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -0
- package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +11 -0
- package/build/codegen/targets/kotlin/emit-route-kotlin.js +73 -0
- package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -0
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +88 -0
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +11 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js +35 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +52 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -0
- package/build/codegen/targets/kotlin/format-kotlin.d.ts +4 -0
- package/build/codegen/targets/kotlin/format-kotlin.js +20 -0
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -0
- package/build/codegen/targets/kotlin/format-kotlin.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/format-kotlin.test.js +24 -0
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -0
- package/build/codegen/targets/kotlin/integration.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/integration.test.js +34 -0
- package/build/codegen/targets/kotlin/integration.test.js.map +1 -0
- package/build/implementations/http/doc-registry.d.ts +14 -19
- package/build/implementations/http/doc-registry.js +41 -46
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +141 -10
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +11 -2
- package/build/implementations/http/error-taxonomy.js +24 -2
- package/build/implementations/http/error-taxonomy.js.map +1 -1
- package/build/implementations/http/route-errors.test.js +5 -6
- package/build/implementations/http/route-errors.test.js.map +1 -1
- package/build/implementations/types.d.ts +13 -1
- package/docs/http-integrations.md +39 -5
- package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
- package/docs/superpowers/plans/2026-04-24-kotlin-codegen-target.md +1265 -0
- package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +401 -0
- package/package.json +1 -1
- package/src/implementations/http/README.md +4 -3
- package/src/implementations/http/doc-registry.test.ts +154 -10
- package/src/implementations/http/doc-registry.ts +46 -53
- package/src/implementations/http/error-taxonomy.ts +26 -2
- package/src/implementations/http/express-rpc/README.md +2 -2
- package/src/implementations/http/hono-rpc/README.md +2 -2
- package/src/implementations/http/hono-stream/README.md +15 -0
- package/src/implementations/http/route-errors.test.ts +5 -6
- package/src/implementations/types.ts +13 -1
|
@@ -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.
|