ts-procedures 6.0.2 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent_config/bin/setup.mjs +0 -0
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -0
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +106 -0
- package/agent_config/copilot/copilot-instructions.md +2 -0
- package/agent_config/cursor/cursorrules +2 -0
- package/build/codegen/bin/cli.d.ts +25 -0
- package/build/codegen/bin/cli.js +88 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +180 -1
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/index.d.ts +19 -0
- package/build/codegen/index.js +5 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +7 -0
- package/build/codegen/pipeline.js +57 -0
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/pipeline.test.js +162 -0
- package/build/codegen/pipeline.test.js.map +1 -1
- package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +6 -4
- package/build/codegen/targets/kotlin/ajsc-adapter.js +12 -7
- package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -1
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js +20 -2
- package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -1
- package/build/codegen/targets/kotlin/e2e-compile.test.js +41 -9
- package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +6 -2
- package/build/codegen/targets/kotlin/emit-route-kotlin.js +18 -11
- package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +120 -1
- package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +4 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js +9 -4
- package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +39 -0
- package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.d.ts +11 -0
- package/build/codegen/targets/kotlin/format-kotlin.js +20 -0
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js +27 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/integration.test.js +26 -9
- package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.d.ts +1 -0
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js +50 -0
- package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js.map +1 -0
- package/build/codegen/test-helpers/golden.d.ts +15 -0
- package/build/codegen/test-helpers/golden.js +30 -0
- package/build/codegen/test-helpers/golden.js.map +1 -0
- package/build/codegen/test-helpers/golden.test.d.ts +1 -0
- package/build/codegen/test-helpers/golden.test.js +76 -0
- package/build/codegen/test-helpers/golden.test.js.map +1 -0
- package/docs/codegen-kotlin.md +175 -0
- package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
- package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
- package/package.json +2 -2
- package/src/codegen/bin/cli.test.ts +200 -1
- package/src/codegen/bin/cli.ts +103 -0
- package/src/codegen/index.ts +27 -0
- package/src/codegen/pipeline.test.ts +175 -0
- package/src/codegen/pipeline.ts +79 -0
- package/src/codegen/targets/kotlin/__fixtures__/users-envelope.json +144 -0
- package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +121 -0
- package/src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap +27 -0
- package/src/codegen/targets/kotlin/ajsc-adapter.test.ts +47 -0
- package/src/codegen/targets/kotlin/ajsc-adapter.ts +66 -0
- package/src/codegen/targets/kotlin/e2e-compile.test.ts +86 -0
- package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +239 -0
- package/src/codegen/targets/kotlin/emit-route-kotlin.ts +109 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +65 -0
- package/src/codegen/targets/kotlin/format-kotlin.test.ts +70 -0
- package/src/codegen/targets/kotlin/format-kotlin.ts +45 -0
- package/src/codegen/targets/kotlin/integration.test.ts +77 -0
- package/src/codegen/targets/kotlin/probe-unsupported-unions.test.ts +64 -0
- package/src/codegen/test-helpers/golden.test.ts +80 -0
- package/src/codegen/test-helpers/golden.ts +34 -0
package/src/codegen/bin/cli.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface CodegenConfig {
|
|
|
22
22
|
selfContained?: boolean
|
|
23
23
|
serviceName?: string
|
|
24
24
|
cleanOutDir?: boolean
|
|
25
|
+
target?: 'ts' | 'kotlin'
|
|
26
|
+
kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
|
|
27
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
export interface ParsedArgs {
|
|
@@ -37,6 +40,9 @@ export interface ParsedArgs {
|
|
|
37
40
|
selfContained: boolean
|
|
38
41
|
serviceName?: string
|
|
39
42
|
cleanOutDir: boolean
|
|
43
|
+
target?: 'ts' | 'kotlin'
|
|
44
|
+
kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
|
|
45
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
// ---------------------------------------------------------------------------
|
|
@@ -88,6 +94,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
88
94
|
let selfContained = config?.selfContained ?? true
|
|
89
95
|
let serviceName: string | undefined = config?.serviceName
|
|
90
96
|
let cleanOutDir = config?.cleanOutDir ?? false
|
|
97
|
+
let target: 'ts' | 'kotlin' | undefined = config?.target
|
|
98
|
+
let kotlinPackage: string | undefined = config?.kotlin?.package
|
|
99
|
+
let kotlinSerializer: 'kotlinx' | 'none' | undefined = config?.kotlin?.serializer
|
|
100
|
+
let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
|
|
91
101
|
let configPath: string | undefined
|
|
92
102
|
|
|
93
103
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -137,6 +147,29 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
137
147
|
cleanOutDir = true
|
|
138
148
|
} else if (arg === '--no-clean-out-dir') {
|
|
139
149
|
cleanOutDir = false
|
|
150
|
+
} else if (arg === '--target') {
|
|
151
|
+
const val = argv[++i]
|
|
152
|
+
if (val === 'ts' || val === 'kotlin') {
|
|
153
|
+
target = val
|
|
154
|
+
} else {
|
|
155
|
+
throw new Error(`Invalid --target value: ${val ?? '(missing)'} (expected 'ts' or 'kotlin')`)
|
|
156
|
+
}
|
|
157
|
+
} else if (arg === '--kotlin-package') {
|
|
158
|
+
kotlinPackage = argv[++i]
|
|
159
|
+
} else if (arg === '--kotlin-serializer') {
|
|
160
|
+
const val = argv[++i]
|
|
161
|
+
if (val === 'kotlinx' || val === 'none') {
|
|
162
|
+
kotlinSerializer = val
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error(`Invalid --kotlin-serializer value: ${val ?? '(missing)'} (expected 'kotlinx' or 'none')`)
|
|
165
|
+
}
|
|
166
|
+
} else if (arg === '--unsupported-unions') {
|
|
167
|
+
const val = argv[++i]
|
|
168
|
+
if (val === 'throw' || val === 'fallback') {
|
|
169
|
+
unsupportedUnions = val
|
|
170
|
+
} else {
|
|
171
|
+
throw new Error(`Invalid --unsupported-unions value: ${val ?? '(missing)'} (expected 'throw' or 'fallback')`)
|
|
172
|
+
}
|
|
140
173
|
} else if (arg === '--config') {
|
|
141
174
|
configPath = argv[++i]
|
|
142
175
|
}
|
|
@@ -153,6 +186,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
153
186
|
throw new Error('Missing required input source: provide --url <url> or --file <path>')
|
|
154
187
|
}
|
|
155
188
|
|
|
189
|
+
if (target === 'kotlin' && (kotlinPackage === undefined || kotlinPackage === '')) {
|
|
190
|
+
throw new Error('Missing required argument: --kotlin-package <pkg> (required when --target kotlin)')
|
|
191
|
+
}
|
|
192
|
+
|
|
156
193
|
return {
|
|
157
194
|
url,
|
|
158
195
|
file,
|
|
@@ -166,6 +203,16 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
166
203
|
selfContained,
|
|
167
204
|
...(serviceName !== undefined ? { serviceName } : {}),
|
|
168
205
|
cleanOutDir,
|
|
206
|
+
...(target !== undefined ? { target } : {}),
|
|
207
|
+
...(kotlinPackage !== undefined
|
|
208
|
+
? {
|
|
209
|
+
kotlin: {
|
|
210
|
+
package: kotlinPackage,
|
|
211
|
+
...(kotlinSerializer !== undefined ? { serializer: kotlinSerializer } : {}),
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
: {}),
|
|
215
|
+
...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
|
|
169
216
|
}
|
|
170
217
|
}
|
|
171
218
|
|
|
@@ -201,6 +248,18 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
|
|
|
201
248
|
cleanOutDir: parsed.cleanOutDir,
|
|
202
249
|
}
|
|
203
250
|
|
|
251
|
+
// Resolve the kotlin emitter once at watch start; it's stateless and reused per tick.
|
|
252
|
+
const kotlinWiring =
|
|
253
|
+
parsed.target === 'kotlin'
|
|
254
|
+
? {
|
|
255
|
+
target: 'kotlin' as const,
|
|
256
|
+
kotlinPackage: parsed.kotlin!.package,
|
|
257
|
+
kotlinEmitter: await (
|
|
258
|
+
await import('../targets/kotlin/ajsc-adapter.js')
|
|
259
|
+
).resolveProductionKotlinEmitter(),
|
|
260
|
+
}
|
|
261
|
+
: {}
|
|
262
|
+
|
|
204
263
|
let lastHash: string | undefined
|
|
205
264
|
|
|
206
265
|
const run = async (): Promise<void> => {
|
|
@@ -226,6 +285,9 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
|
|
|
226
285
|
selfContained: parsed.selfContained,
|
|
227
286
|
serviceName: parsed.serviceName,
|
|
228
287
|
cleanOutDir: parsed.cleanOutDir,
|
|
288
|
+
...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
|
|
289
|
+
...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
|
|
290
|
+
...kotlinWiring,
|
|
229
291
|
})
|
|
230
292
|
console.log(`[ts-procedures-codegen] Generated client files → ${parsed.outDir}`)
|
|
231
293
|
} catch (err) {
|
|
@@ -243,6 +305,33 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
|
|
|
243
305
|
// Main
|
|
244
306
|
// ---------------------------------------------------------------------------
|
|
245
307
|
|
|
308
|
+
const KOTLIN_SETUP_GUIDE_URL =
|
|
309
|
+
'https://bitbucket.org/thermsio/ts-procedures/src/master/docs/codegen-kotlin.md'
|
|
310
|
+
|
|
311
|
+
export function printPostRunHints(parsed: { target?: 'ts' | 'kotlin' }): void {
|
|
312
|
+
if (parsed.target === 'kotlin') {
|
|
313
|
+
console.log(`[ts-procedures-codegen] Kotlin setup guide: ${KOTLIN_SETUP_GUIDE_URL}`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Warns about flags that are currently no-ops for the Kotlin target.
|
|
319
|
+
* Currently: `--unsupported-unions` is a no-op because ajsc v7.2's Kotlin
|
|
320
|
+
* emitter silently emits an empty data class for untagged oneOf regardless
|
|
321
|
+
* of the flag (see docs/codegen-kotlin.md#untagged-unions).
|
|
322
|
+
*/
|
|
323
|
+
export function warnIfKotlinNoOpFlags(parsed: {
|
|
324
|
+
target?: 'ts' | 'kotlin'
|
|
325
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
326
|
+
}): void {
|
|
327
|
+
if (parsed.target === 'kotlin' && parsed.unsupportedUnions !== undefined) {
|
|
328
|
+
console.warn(
|
|
329
|
+
'[ts-procedures-codegen] Note: --unsupported-unions is currently a no-op for --target kotlin ' +
|
|
330
|
+
'(ajsc v7.2 emits an empty data class regardless). See docs/codegen-kotlin.md#untagged-unions.',
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
246
335
|
async function main(): Promise<void> {
|
|
247
336
|
const argv = process.argv.slice(2)
|
|
248
337
|
const configPath = extractConfigPath(argv)
|
|
@@ -251,6 +340,7 @@ async function main(): Promise<void> {
|
|
|
251
340
|
console.log(`[ts-procedures-codegen] Loaded config from ${configPath ?? DEFAULT_CONFIG_NAME}`)
|
|
252
341
|
}
|
|
253
342
|
const parsed = parseArgs(argv, config)
|
|
343
|
+
warnIfKotlinNoOpFlags(parsed)
|
|
254
344
|
|
|
255
345
|
const source = parsed.url ?? parsed.file!
|
|
256
346
|
console.log(`[ts-procedures-codegen] Reading docs from ${source}...`)
|
|
@@ -258,6 +348,15 @@ async function main(): Promise<void> {
|
|
|
258
348
|
if (parsed.watch) {
|
|
259
349
|
await runWithWatch(parsed)
|
|
260
350
|
} else {
|
|
351
|
+
const kotlinWiring =
|
|
352
|
+
parsed.target === 'kotlin'
|
|
353
|
+
? {
|
|
354
|
+
target: 'kotlin' as const,
|
|
355
|
+
kotlinPackage: parsed.kotlin!.package,
|
|
356
|
+
kotlinEmitter: await (await import('../targets/kotlin/ajsc-adapter.js')).resolveProductionKotlinEmitter(),
|
|
357
|
+
}
|
|
358
|
+
: {}
|
|
359
|
+
|
|
261
360
|
const result = await generateClient({
|
|
262
361
|
url: parsed.url,
|
|
263
362
|
file: parsed.file,
|
|
@@ -269,11 +368,15 @@ async function main(): Promise<void> {
|
|
|
269
368
|
selfContained: parsed.selfContained,
|
|
270
369
|
serviceName: parsed.serviceName,
|
|
271
370
|
cleanOutDir: parsed.cleanOutDir,
|
|
371
|
+
...(parsed.kotlin?.serializer !== undefined ? { kotlinSerializer: parsed.kotlin.serializer } : {}),
|
|
372
|
+
...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
|
|
373
|
+
...kotlinWiring,
|
|
272
374
|
})
|
|
273
375
|
if (parsed.dryRun) {
|
|
274
376
|
console.log(`[ts-procedures-codegen] Dry run complete — ${result.length} files would be generated`)
|
|
275
377
|
} else {
|
|
276
378
|
console.log(`[ts-procedures-codegen] Generated ${result.length} files → ${parsed.outDir}`)
|
|
379
|
+
printPostRunHints(parsed)
|
|
277
380
|
}
|
|
278
381
|
}
|
|
279
382
|
}
|
package/src/codegen/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveEnvelope, type ResolveInput } from './resolve-envelope.js'
|
|
2
2
|
import { runPipeline, type GeneratedFile } from './pipeline.js'
|
|
3
3
|
import type { AjscOptions } from './emit-types.js'
|
|
4
|
+
import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
4
5
|
|
|
5
6
|
export interface GenerateClientOptions extends ResolveInput {
|
|
6
7
|
outDir: string
|
|
@@ -11,6 +12,12 @@ export interface GenerateClientOptions extends ResolveInput {
|
|
|
11
12
|
selfContained?: boolean
|
|
12
13
|
serviceName?: string
|
|
13
14
|
cleanOutDir?: boolean
|
|
15
|
+
target?: 'ts' | 'kotlin'
|
|
16
|
+
kotlinPackage?: string
|
|
17
|
+
kotlinSerializer?: 'kotlinx' | 'none'
|
|
18
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
19
|
+
/** Injected for tests; production wiring resolves a real ajsc emitter. */
|
|
20
|
+
kotlinEmitter?: KotlinEmitter
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export async function generateClient(options: GenerateClientOptions): Promise<GeneratedFile[]> {
|
|
@@ -25,8 +32,28 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
|
|
|
25
32
|
selfContained: options.selfContained,
|
|
26
33
|
serviceName: options.serviceName,
|
|
27
34
|
cleanOutDir: options.cleanOutDir,
|
|
35
|
+
target: options.target,
|
|
36
|
+
kotlinPackage: options.kotlinPackage,
|
|
37
|
+
kotlinSerializer: options.kotlinSerializer,
|
|
38
|
+
unsupportedUnions: options.unsupportedUnions,
|
|
39
|
+
kotlinEmitter: options.kotlinEmitter,
|
|
28
40
|
})
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export type { AjscOptions } from './emit-types.js'
|
|
32
44
|
export type { ResolveInput } from './resolve-envelope.js'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @internal Subject to change with ajsc minor versions. Use for test injection
|
|
48
|
+
* only; consumer code should not depend on this shape.
|
|
49
|
+
*/
|
|
50
|
+
export type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @internal Mirrors ajsc's `KotlinConverterOpts` and may shift with ajsc
|
|
54
|
+
* minor versions. Use for test injection only.
|
|
55
|
+
*/
|
|
56
|
+
export type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
|
|
57
|
+
|
|
58
|
+
/** Result shape produced by `KotlinEmitter.emit`; stable, useful for stub builders in tests. */
|
|
59
|
+
export type { KotlinEmitResult } from './targets/kotlin/ajsc-adapter.js'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach, vi } from 'vitest'
|
|
2
2
|
import { generateClient } from './index.js'
|
|
3
3
|
import { runPipeline } from './pipeline.js'
|
|
4
|
+
import { createStubKotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
5
|
+
import type { KotlinEmitOptions } from './targets/kotlin/ajsc-adapter.js'
|
|
4
6
|
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from 'node:fs'
|
|
5
7
|
import { join } from 'node:path'
|
|
6
8
|
import { tmpdir } from 'node:os'
|
|
@@ -359,3 +361,176 @@ describe('generateClient pipeline', () => {
|
|
|
359
361
|
)
|
|
360
362
|
})
|
|
361
363
|
})
|
|
364
|
+
|
|
365
|
+
describe('runPipeline (kotlin target)', () => {
|
|
366
|
+
it('emits only .kt files when target is "kotlin"', async () => {
|
|
367
|
+
const envelope = {
|
|
368
|
+
basePath: '/api',
|
|
369
|
+
headers: [],
|
|
370
|
+
version: '1' as const,
|
|
371
|
+
routes: [
|
|
372
|
+
{
|
|
373
|
+
kind: 'api',
|
|
374
|
+
name: 'GetUser',
|
|
375
|
+
scope: 'users',
|
|
376
|
+
method: 'GET',
|
|
377
|
+
fullPath: '/users/:id',
|
|
378
|
+
schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
|
|
379
|
+
errors: [],
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
errors: [],
|
|
383
|
+
} as any
|
|
384
|
+
|
|
385
|
+
const files = await runPipeline({
|
|
386
|
+
envelope,
|
|
387
|
+
outDir: 'out',
|
|
388
|
+
dryRun: true,
|
|
389
|
+
target: 'kotlin',
|
|
390
|
+
kotlinPackage: 'com.example.api',
|
|
391
|
+
kotlinEmitter: createStubKotlinEmitter({
|
|
392
|
+
PathParams: {
|
|
393
|
+
code: '@Serializable data class PathParams(val id: String)',
|
|
394
|
+
rootTypeName: 'PathParams',
|
|
395
|
+
extractedTypeNames: [],
|
|
396
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
397
|
+
},
|
|
398
|
+
Response: {
|
|
399
|
+
code: '@Serializable data class Response(val id: String)',
|
|
400
|
+
rootTypeName: 'Response',
|
|
401
|
+
extractedTypeNames: [],
|
|
402
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
403
|
+
},
|
|
404
|
+
}),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
expect(files.map((f) => f.path)).toEqual([join('out', 'Users.kt')])
|
|
408
|
+
expect(files[0]!.code).toContain('object Users {')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('does not emit _errors.ts, index.ts, or client runtime files when target is "kotlin"', async () => {
|
|
412
|
+
const envelope = { basePath: '/api', headers: [], version: '1', routes: [], errors: [] } as any
|
|
413
|
+
const files = await runPipeline({
|
|
414
|
+
envelope,
|
|
415
|
+
outDir: 'out',
|
|
416
|
+
dryRun: true,
|
|
417
|
+
target: 'kotlin',
|
|
418
|
+
kotlinPackage: 'p',
|
|
419
|
+
kotlinEmitter: createStubKotlinEmitter({}),
|
|
420
|
+
})
|
|
421
|
+
expect(files.find((f) => f.path.endsWith('_errors.ts'))).toBeUndefined()
|
|
422
|
+
expect(files.find((f) => f.path.endsWith('index.ts'))).toBeUndefined()
|
|
423
|
+
expect(files.find((f) => f.path.endsWith('_client.ts'))).toBeUndefined()
|
|
424
|
+
expect(files.find((f) => f.path.endsWith('_types.ts'))).toBeUndefined()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('threads kotlinSerializer and unsupportedUnions to the emitter', async () => {
|
|
428
|
+
const calls: KotlinEmitOptions[] = []
|
|
429
|
+
const captureEmitter = {
|
|
430
|
+
emit(_s: unknown, opts: KotlinEmitOptions) {
|
|
431
|
+
calls.push(opts)
|
|
432
|
+
return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const envelope = {
|
|
437
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
438
|
+
routes: [
|
|
439
|
+
{
|
|
440
|
+
kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/users/:id',
|
|
441
|
+
schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
|
|
442
|
+
errors: [],
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
} as any
|
|
446
|
+
|
|
447
|
+
await runPipeline({
|
|
448
|
+
envelope, outDir: 'out', dryRun: true,
|
|
449
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
450
|
+
kotlinSerializer: 'none',
|
|
451
|
+
unsupportedUnions: 'fallback',
|
|
452
|
+
kotlinEmitter: captureEmitter,
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
456
|
+
for (const c of calls) {
|
|
457
|
+
expect(c.serializer).toBe('none')
|
|
458
|
+
expect(c.unsupportedUnions).toBe('fallback')
|
|
459
|
+
expect(c.inlineTypes).toBe(true)
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('logs a single summary line for skipped stream routes', async () => {
|
|
464
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
465
|
+
try {
|
|
466
|
+
const envelope = {
|
|
467
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
468
|
+
routes: [
|
|
469
|
+
{ kind: 'stream', name: 'WatchA', scope: 's', method: 'GET', path: '/a', schema: {}, errors: [] },
|
|
470
|
+
{ kind: 'stream', name: 'WatchB', scope: 's', method: 'GET', path: '/b', schema: {}, errors: [] },
|
|
471
|
+
{ kind: 'api', name: 'GetThing', scope: 's', method: 'GET', fullPath: '/c', schema: { returnType: { type: 'object' } }, errors: [] },
|
|
472
|
+
],
|
|
473
|
+
} as any
|
|
474
|
+
await runPipeline({
|
|
475
|
+
envelope, outDir: 'out', dryRun: true,
|
|
476
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
477
|
+
kotlinEmitter: createStubKotlinEmitter({
|
|
478
|
+
Response: { code: 'data class Response(val ok: Boolean)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] },
|
|
479
|
+
}),
|
|
480
|
+
})
|
|
481
|
+
const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
|
|
482
|
+
expect(summary).toBeDefined()
|
|
483
|
+
expect(String(summary![0])).toContain('Skipped 2 stream routes')
|
|
484
|
+
expect(String(summary![0])).toContain('WatchA')
|
|
485
|
+
expect(String(summary![0])).toContain('WatchB')
|
|
486
|
+
} finally {
|
|
487
|
+
logSpy.mockRestore()
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('does not log a summary when there are no skipped streams', async () => {
|
|
492
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
493
|
+
try {
|
|
494
|
+
const envelope = {
|
|
495
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
496
|
+
routes: [{ kind: 'api', name: 'X', scope: 's', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] }],
|
|
497
|
+
} as any
|
|
498
|
+
await runPipeline({
|
|
499
|
+
envelope, outDir: 'out', dryRun: true,
|
|
500
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
501
|
+
kotlinEmitter: createStubKotlinEmitter({ Response: { code: 'data class Response(val x: Int)', rootTypeName: 'Response', extractedTypeNames: [], imports: [] } }),
|
|
502
|
+
})
|
|
503
|
+
const summary = logSpy.mock.calls.find((c) => String(c[0]).includes('Skipped'))
|
|
504
|
+
expect(summary).toBeUndefined()
|
|
505
|
+
} finally {
|
|
506
|
+
logSpy.mockRestore()
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('forwards ajsc passthroughs (arrayItemNaming/depluralize/uncountableWords) to the kotlin emitter', async () => {
|
|
511
|
+
const calls: KotlinEmitOptions[] = []
|
|
512
|
+
const captureEmitter = {
|
|
513
|
+
emit(_s: unknown, opts: KotlinEmitOptions) {
|
|
514
|
+
calls.push(opts)
|
|
515
|
+
return { code: 'data class Response(val id: String)', rootTypeName: opts.rootTypeName, extractedTypeNames: [], imports: [] }
|
|
516
|
+
},
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const envelope = {
|
|
520
|
+
basePath: '/api', headers: [], version: '1' as const, errors: [],
|
|
521
|
+
routes: [{ kind: 'api', name: 'GetUser', scope: 'users', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] }],
|
|
522
|
+
} as any
|
|
523
|
+
|
|
524
|
+
await runPipeline({
|
|
525
|
+
envelope, outDir: 'out', dryRun: true,
|
|
526
|
+
target: 'kotlin', kotlinPackage: 'p',
|
|
527
|
+
ajsc: { arrayItemNaming: 'Item', depluralize: true, uncountableWords: ['data'] },
|
|
528
|
+
kotlinEmitter: captureEmitter,
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
expect(calls.length).toBe(1)
|
|
532
|
+
expect(calls[0]!.arrayItemNaming).toBe('Item')
|
|
533
|
+
expect(calls[0]!.depluralize).toBe(true)
|
|
534
|
+
expect(calls[0]!.uncountableWords).toEqual(['data'])
|
|
535
|
+
})
|
|
536
|
+
})
|
package/src/codegen/pipeline.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { emitErrorsFile } from './emit-errors.js'
|
|
|
10
10
|
import { emitClientTypesFile } from './emit-client-types.js'
|
|
11
11
|
import { emitClientRuntimeFile } from './emit-client-runtime.js'
|
|
12
12
|
import { validateServiceName } from './naming.js'
|
|
13
|
+
import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
|
|
14
|
+
import { emitKotlinScope } from './targets/kotlin/emit-scope-kotlin.js'
|
|
15
|
+
import { pickDefined } from './targets/kotlin/format-kotlin.js'
|
|
13
16
|
|
|
14
17
|
export interface PipelineOptions {
|
|
15
18
|
envelope: DocEnvelope
|
|
@@ -21,6 +24,12 @@ export interface PipelineOptions {
|
|
|
21
24
|
selfContained?: boolean
|
|
22
25
|
serviceName?: string
|
|
23
26
|
cleanOutDir?: boolean
|
|
27
|
+
target?: 'ts' | 'kotlin'
|
|
28
|
+
kotlinPackage?: string
|
|
29
|
+
kotlinSerializer?: 'kotlinx' | 'none'
|
|
30
|
+
unsupportedUnions?: 'throw' | 'fallback'
|
|
31
|
+
/** Injected for tests; production wiring resolves a real ajsc emitter. */
|
|
32
|
+
kotlinEmitter?: KotlinEmitter
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export interface GeneratedFile {
|
|
@@ -43,6 +52,76 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
|
|
|
43
52
|
const groups = groupRoutesByScope(envelope.routes)
|
|
44
53
|
const groupArray = Array.from(groups.values())
|
|
45
54
|
|
|
55
|
+
if (options.target === 'kotlin') {
|
|
56
|
+
if (options.kotlinPackage == null) {
|
|
57
|
+
throw new Error('[ts-procedures-codegen] target=kotlin requires kotlinPackage')
|
|
58
|
+
}
|
|
59
|
+
if (options.kotlinEmitter == null) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'[ts-procedures-codegen] target=kotlin requires a kotlinEmitter (CLI resolves via resolveProductionKotlinEmitter)'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Route errors are taxonomy keys (string[]); look up actual schemas from the
|
|
66
|
+
// envelope's top-level errors array. Mirrors the existing TS scope emitter.
|
|
67
|
+
const errorSchemas = new Map<string, unknown>()
|
|
68
|
+
for (const e of envelope.errors) {
|
|
69
|
+
if (e.schema != null) errorSchemas.set(e.name, e.schema)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Spec §"CLI flags" — `--array-item-naming`, `--depluralize`, `--uncountable-words`
|
|
73
|
+
// also apply to the Kotlin target. The CLI parks them on `options.ajsc` (a TS-target
|
|
74
|
+
// structure historically); copy the Kotlin-relevant subset onto the scope opts here.
|
|
75
|
+
const ajscPassthrough = options.ajsc ?? {}
|
|
76
|
+
|
|
77
|
+
const AJSC_PASSTHROUGH_KEYS = ['arrayItemNaming', 'depluralize', 'uncountableWords'] as const
|
|
78
|
+
|
|
79
|
+
const kotlinFiles: GeneratedFile[] = []
|
|
80
|
+
const allSkipped: string[] = []
|
|
81
|
+
for (const group of groupArray) {
|
|
82
|
+
const emitted = emitKotlinScope(
|
|
83
|
+
group,
|
|
84
|
+
{
|
|
85
|
+
kotlinPackage: options.kotlinPackage,
|
|
86
|
+
sourceHash: hash,
|
|
87
|
+
...(options.kotlinSerializer !== undefined ? { serializer: options.kotlinSerializer } : {}),
|
|
88
|
+
...(options.unsupportedUnions !== undefined ? { unsupportedUnions: options.unsupportedUnions } : {}),
|
|
89
|
+
...pickDefined(ajscPassthrough, AJSC_PASSTHROUGH_KEYS),
|
|
90
|
+
},
|
|
91
|
+
options.kotlinEmitter,
|
|
92
|
+
errorSchemas,
|
|
93
|
+
)
|
|
94
|
+
kotlinFiles.push({ path: join(outDir, emitted.filename), code: emitted.code })
|
|
95
|
+
allSkipped.push(...emitted.skippedStreams)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (allSkipped.length > 0) {
|
|
99
|
+
console.log(
|
|
100
|
+
`[ts-procedures-codegen] Skipped ${allSkipped.length} stream route${allSkipped.length === 1 ? '' : 's'} (kotlin target): ${allSkipped.join(', ')}`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (dryRun) {
|
|
105
|
+
if (cleanOutDir) {
|
|
106
|
+
console.log(`[dry-run] Would clean outDir: ${outDir}`)
|
|
107
|
+
}
|
|
108
|
+
for (const f of kotlinFiles) {
|
|
109
|
+
const bytes = Buffer.byteLength(f.code, 'utf-8')
|
|
110
|
+
console.log(`[dry-run] Would write: ${f.path} (${bytes} bytes)`)
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
if (cleanOutDir) {
|
|
114
|
+
await rm(outDir, { recursive: true, force: true })
|
|
115
|
+
}
|
|
116
|
+
await mkdir(outDir, { recursive: true })
|
|
117
|
+
for (const f of kotlinFiles) {
|
|
118
|
+
await writeFile(f.path, f.code, 'utf-8')
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return kotlinFiles
|
|
123
|
+
}
|
|
124
|
+
|
|
46
125
|
// Error keys that will be emitted in `_errors.ts` — only those with a schema.
|
|
47
126
|
// Scope emit uses this to filter `route.errors` so generated code never
|
|
48
127
|
// references an undefined error type.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1",
|
|
3
|
+
"basePath": "/api",
|
|
4
|
+
"headers": [],
|
|
5
|
+
"routes": [
|
|
6
|
+
{
|
|
7
|
+
"kind": "api",
|
|
8
|
+
"name": "GetUser",
|
|
9
|
+
"scope": "users",
|
|
10
|
+
"method": "GET",
|
|
11
|
+
"fullPath": "/users/:id",
|
|
12
|
+
"schema": {
|
|
13
|
+
"input": {
|
|
14
|
+
"pathParams": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": { "id": { "type": "string" } },
|
|
17
|
+
"required": ["id"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"returnType": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"id": { "type": "string" },
|
|
24
|
+
"name": { "type": "string" },
|
|
25
|
+
"created-at": { "type": "string", "format": "date-time" },
|
|
26
|
+
"address": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"properties": {
|
|
29
|
+
"street": { "type": "string" },
|
|
30
|
+
"city": { "type": "string" }
|
|
31
|
+
},
|
|
32
|
+
"required": ["street", "city"]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"required": ["id", "name", "created-at", "address"]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"errors": ["NotFound"]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"kind": "api",
|
|
42
|
+
"name": "CreateUser",
|
|
43
|
+
"scope": "users",
|
|
44
|
+
"method": "POST",
|
|
45
|
+
"fullPath": "/users",
|
|
46
|
+
"schema": {
|
|
47
|
+
"input": {
|
|
48
|
+
"body": {
|
|
49
|
+
"oneOf": [
|
|
50
|
+
{
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"kind": { "const": "guest" },
|
|
54
|
+
"displayName": { "type": "string" }
|
|
55
|
+
},
|
|
56
|
+
"required": ["kind", "displayName"]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"type": "object",
|
|
60
|
+
"properties": {
|
|
61
|
+
"kind": { "const": "registered" },
|
|
62
|
+
"email": { "type": "string" },
|
|
63
|
+
"name": { "type": "string" }
|
|
64
|
+
},
|
|
65
|
+
"required": ["kind", "email", "name"]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"returnType": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": { "id": { "type": "string" } },
|
|
73
|
+
"required": ["id"]
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"errors": ["ValidationError"]
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"kind": "api",
|
|
80
|
+
"name": "ListUsers",
|
|
81
|
+
"scope": "users",
|
|
82
|
+
"method": "GET",
|
|
83
|
+
"fullPath": "/users",
|
|
84
|
+
"schema": {
|
|
85
|
+
"input": {
|
|
86
|
+
"query": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {
|
|
89
|
+
"status": { "type": "string", "enum": ["active", "inactive"] },
|
|
90
|
+
"limit": { "type": "integer" }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"returnType": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"items": {
|
|
98
|
+
"type": "array",
|
|
99
|
+
"items": {
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
"id": { "type": "string" },
|
|
103
|
+
"name": { "type": "string" }
|
|
104
|
+
},
|
|
105
|
+
"required": ["id", "name"]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"required": ["items"]
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
"errors": []
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
"errors": [
|
|
116
|
+
{
|
|
117
|
+
"name": "NotFound",
|
|
118
|
+
"statusCode": 404,
|
|
119
|
+
"description": "Resource not found",
|
|
120
|
+
"schema": {
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"name": { "const": "NotFound" },
|
|
124
|
+
"message": { "type": "string" }
|
|
125
|
+
},
|
|
126
|
+
"required": ["name", "message"]
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "ValidationError",
|
|
131
|
+
"statusCode": 400,
|
|
132
|
+
"description": "Input failed validation",
|
|
133
|
+
"schema": {
|
|
134
|
+
"type": "object",
|
|
135
|
+
"properties": {
|
|
136
|
+
"name": { "const": "ValidationError" },
|
|
137
|
+
"message": { "type": "string" },
|
|
138
|
+
"field": { "type": "string" }
|
|
139
|
+
},
|
|
140
|
+
"required": ["name", "message"]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
}
|