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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
|
|
2
|
+
import type { KotlinEmitter, KotlinEmitOptions } from './ajsc-adapter.js'
|
|
3
|
+
import { indent, pickDefined } from './format-kotlin.js'
|
|
4
|
+
|
|
5
|
+
export interface EmitRouteResult {
|
|
6
|
+
/** Inner body of the `object RouteName { ... }` block — already indented one level. */
|
|
7
|
+
code: string
|
|
8
|
+
/** Imports collected from every ajsc emit + any helpers this route used. */
|
|
9
|
+
imports: string[]
|
|
10
|
+
/** Outer route name used as the `object RouteName` identifier. */
|
|
11
|
+
routeName: string
|
|
12
|
+
/** True when the route was a stream (out-of-scope). Caller logs once. */
|
|
13
|
+
skipped?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Subset of KotlinEmitOptions threaded by the pipeline; per-call rootTypeName is set inside. */
|
|
17
|
+
export type EmitRouteOpts = Omit<KotlinEmitOptions, 'rootTypeName' | 'inlineTypes'>
|
|
18
|
+
|
|
19
|
+
const COLON_PARAM_RE = /:([A-Za-z_][A-Za-z0-9_]*)/g
|
|
20
|
+
|
|
21
|
+
function toBracePath(template: string): string {
|
|
22
|
+
return template.replace(COLON_PARAM_RE, '{$1}')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pathParamNames(template: string): string[] {
|
|
26
|
+
const names: string[] = []
|
|
27
|
+
for (const match of template.matchAll(COLON_PARAM_RE)) names.push(match[1]!)
|
|
28
|
+
return names
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildPathFn(bracePath: string, params: string[]): string {
|
|
32
|
+
if (params.length === 0) return `const val path = "${bracePath}"`
|
|
33
|
+
let body = bracePath
|
|
34
|
+
for (const name of params) body = body.replace(`{${name}}`, `\${p.${name}}`)
|
|
35
|
+
return `fun path(p: PathParams): String = "${body}"`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function emitOptsFor(rootTypeName: string, routeOpts: EmitRouteOpts): KotlinEmitOptions {
|
|
39
|
+
const PASSTHROUGH_KEYS = ['serializer', 'unsupportedUnions', 'arrayItemNaming', 'depluralize', 'uncountableWords'] as const
|
|
40
|
+
return {
|
|
41
|
+
rootTypeName,
|
|
42
|
+
inlineTypes: true,
|
|
43
|
+
...pickDefined(routeOpts, PASSTHROUGH_KEYS),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function emitKotlinRoute(
|
|
48
|
+
route: AnyHttpRouteDoc,
|
|
49
|
+
emitter: KotlinEmitter,
|
|
50
|
+
errorSchemas: Map<string, unknown>,
|
|
51
|
+
routeOpts: EmitRouteOpts = {},
|
|
52
|
+
): EmitRouteResult {
|
|
53
|
+
const kind = (route as { kind?: string }).kind
|
|
54
|
+
if (kind === 'stream') {
|
|
55
|
+
return { code: '', imports: [], routeName: route.name, skipped: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isApi = kind === 'api' || 'fullPath' in route
|
|
59
|
+
const rawPath = isApi ? (route as { fullPath: string }).fullPath : (route as { path: string }).path
|
|
60
|
+
const method = String((route as { method: string }).method).toUpperCase()
|
|
61
|
+
const bracePath = toBracePath(rawPath)
|
|
62
|
+
const params = pathParamNames(rawPath)
|
|
63
|
+
|
|
64
|
+
const lines: string[] = [
|
|
65
|
+
`const val method = "${method}"`,
|
|
66
|
+
`const val pathTemplate = "${bracePath}"`,
|
|
67
|
+
buildPathFn(bracePath, params),
|
|
68
|
+
]
|
|
69
|
+
const imports: string[] = []
|
|
70
|
+
|
|
71
|
+
const schema = (route as { schema?: Record<string, unknown> }).schema ?? {}
|
|
72
|
+
const input = (schema.input ?? {}) as Record<string, unknown>
|
|
73
|
+
|
|
74
|
+
// Per-slot emission. Order is fixed for deterministic output.
|
|
75
|
+
const slots: Array<{ rootName: string; source: unknown }> = [
|
|
76
|
+
{ rootName: 'PathParams', source: input.pathParams },
|
|
77
|
+
{ rootName: 'Query', source: input.query },
|
|
78
|
+
{ rootName: 'Body', source: input.body },
|
|
79
|
+
{ rootName: 'Response', source: schema.returnType },
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
for (const slot of slots) {
|
|
83
|
+
if (slot.source == null) continue
|
|
84
|
+
const result = emitter.emit(slot.source as Record<string, unknown>, emitOptsFor(slot.rootName, routeOpts))
|
|
85
|
+
lines.push('')
|
|
86
|
+
lines.push(result.code)
|
|
87
|
+
imports.push(...result.imports)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Errors namespace — route.errors is `string[]` of taxonomy keys; look up each schema
|
|
91
|
+
// from the envelope-level errors map. Keys without schemas are skipped silently
|
|
92
|
+
// (matching the existing TS scope emitter's `errorKeys` filter).
|
|
93
|
+
const routeErrorKeys = ((route as { errors?: string[] }).errors ?? [])
|
|
94
|
+
.filter((key) => errorSchemas.has(key))
|
|
95
|
+
if (routeErrorKeys.length > 0) {
|
|
96
|
+
const inner: string[] = []
|
|
97
|
+
for (const key of routeErrorKeys) {
|
|
98
|
+
const r = emitter.emit(errorSchemas.get(key) as Record<string, unknown>, emitOptsFor(key, routeOpts))
|
|
99
|
+
inner.push(r.code)
|
|
100
|
+
imports.push(...r.imports)
|
|
101
|
+
}
|
|
102
|
+
lines.push('')
|
|
103
|
+
lines.push('object Errors {')
|
|
104
|
+
lines.push(indent(inner.join('\n\n'), 1))
|
|
105
|
+
lines.push('}')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { code: lines.join('\n'), imports, routeName: route.name, skipped: false }
|
|
109
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AnyHttpRouteDoc } from '../../../implementations/types.js'
|
|
3
|
+
import type { ScopeGroup } from '../../group-routes.js'
|
|
4
|
+
import { emitKotlinScope } from './emit-scope-kotlin.js'
|
|
5
|
+
import { createStubKotlinEmitter, type KotlinEmitOptions, type KotlinEmitResult } from './ajsc-adapter.js'
|
|
6
|
+
|
|
7
|
+
const ok = (code: string, rootTypeName: string): KotlinEmitResult => ({
|
|
8
|
+
code,
|
|
9
|
+
rootTypeName,
|
|
10
|
+
extractedTypeNames: [],
|
|
11
|
+
imports: ['kotlinx.serialization.Serializable'],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('emitKotlinScope', () => {
|
|
15
|
+
it('produces a complete kotlin source file for a single-route scope', () => {
|
|
16
|
+
const route: AnyHttpRouteDoc = {
|
|
17
|
+
kind: 'api',
|
|
18
|
+
name: 'GetUser',
|
|
19
|
+
method: 'GET',
|
|
20
|
+
fullPath: '/users/:id',
|
|
21
|
+
schema: { input: { pathParams: { type: 'object' } }, returnType: { type: 'object' } },
|
|
22
|
+
errors: [],
|
|
23
|
+
} as unknown as AnyHttpRouteDoc
|
|
24
|
+
|
|
25
|
+
const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route] }
|
|
26
|
+
const emitter = createStubKotlinEmitter({
|
|
27
|
+
PathParams: ok('@Serializable data class PathParams(val id: String)', 'PathParams'),
|
|
28
|
+
Response: ok('@Serializable data class Response(val id: String)', 'Response'),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const file = emitKotlinScope(group, { kotlinPackage: 'com.example.api', sourceHash: 'abc123' }, emitter, new Map())
|
|
32
|
+
|
|
33
|
+
expect(file.filename).toBe('Users.kt')
|
|
34
|
+
expect(file.code).toContain('package com.example.api')
|
|
35
|
+
expect(file.code).toContain('// Source hash: abc123')
|
|
36
|
+
expect(file.code).toContain('import kotlinx.serialization.Serializable')
|
|
37
|
+
expect(file.code).toContain('object Users {')
|
|
38
|
+
expect(file.code).toContain('object GetUser {')
|
|
39
|
+
expect(file.code).toContain('const val method = "GET"')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('joins multiple routes inside one scope object', () => {
|
|
43
|
+
const route1 = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/users/:id', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
44
|
+
const route2 = { kind: 'api', name: 'CreateUser', method: 'POST', fullPath: '/users', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
45
|
+
const group: ScopeGroup = { scopeKey: 'users', camelCase: 'users', routes: [route1, route2] }
|
|
46
|
+
const emitter = createStubKotlinEmitter({})
|
|
47
|
+
|
|
48
|
+
const file = emitKotlinScope(group, { kotlinPackage: 'com.example.api', sourceHash: 'h' }, emitter, new Map())
|
|
49
|
+
expect(file.code).toContain('object GetUser {')
|
|
50
|
+
expect(file.code).toContain('object CreateUser {')
|
|
51
|
+
// exactly one outer scope object
|
|
52
|
+
expect((file.code.match(/^object Users \{/gm) ?? []).length).toBe(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('uses PascalCase scope name for the filename and outer object', () => {
|
|
56
|
+
const group: ScopeGroup = { scopeKey: 'admin-users', camelCase: 'adminUsers', routes: [] }
|
|
57
|
+
const file = emitKotlinScope(group, { kotlinPackage: 'p', sourceHash: 'h' }, createStubKotlinEmitter({}), new Map())
|
|
58
|
+
expect(file.filename).toBe('AdminUsers.kt')
|
|
59
|
+
expect(file.code).toContain('object AdminUsers {')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('threads all 5 passthrough opts to every emitter call', () => {
|
|
63
|
+
const route = { kind: 'api', name: 'X', method: 'GET', fullPath: '/x', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
|
|
64
|
+
const group: ScopeGroup = { scopeKey: 'x', camelCase: 'x', routes: [route] }
|
|
65
|
+
|
|
66
|
+
const calls: KotlinEmitOptions[] = []
|
|
67
|
+
const emitter = {
|
|
68
|
+
emit(_s: unknown, opts: KotlinEmitOptions) {
|
|
69
|
+
calls.push(opts)
|
|
70
|
+
return { code: 'data class Response', rootTypeName: 'Response', extractedTypeNames: [], imports: [] }
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
emitKotlinScope(
|
|
75
|
+
group,
|
|
76
|
+
{
|
|
77
|
+
kotlinPackage: 'p',
|
|
78
|
+
sourceHash: 'h',
|
|
79
|
+
serializer: 'none',
|
|
80
|
+
unsupportedUnions: 'fallback',
|
|
81
|
+
arrayItemNaming: false,
|
|
82
|
+
depluralize: true,
|
|
83
|
+
uncountableWords: ['data', 'metadata'],
|
|
84
|
+
},
|
|
85
|
+
emitter,
|
|
86
|
+
new Map(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(calls.length).toBe(1)
|
|
90
|
+
expect(calls[0]!.inlineTypes).toBe(true)
|
|
91
|
+
expect(calls[0]!.serializer).toBe('none')
|
|
92
|
+
expect(calls[0]!.unsupportedUnions).toBe('fallback')
|
|
93
|
+
expect(calls[0]!.arrayItemNaming).toBe(false)
|
|
94
|
+
expect(calls[0]!.depluralize).toBe(true)
|
|
95
|
+
expect(calls[0]!.uncountableWords).toEqual(['data', 'metadata'])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('collects skipped stream-route names', () => {
|
|
99
|
+
const stream = { kind: 'stream', name: 'WatchUsers', method: 'GET', path: '/u/stream', schema: {}, errors: [] } as unknown as AnyHttpRouteDoc
|
|
100
|
+
const api = { kind: 'api', name: 'GetUser', method: 'GET', fullPath: '/u', schema: { returnType: { type: 'object' } }, errors: [] } as unknown as AnyHttpRouteDoc
|
|
101
|
+
const group: ScopeGroup = { scopeKey: 'u', camelCase: 'u', routes: [stream, api] }
|
|
102
|
+
|
|
103
|
+
const emitter = createStubKotlinEmitter({
|
|
104
|
+
Response: ok('data class Response(val id: String)', 'Response'),
|
|
105
|
+
})
|
|
106
|
+
const result = emitKotlinScope(group, { kotlinPackage: 'p', sourceHash: 'h' }, emitter, new Map())
|
|
107
|
+
|
|
108
|
+
expect(result.skippedStreams).toEqual(['WatchUsers'])
|
|
109
|
+
expect(result.code).toContain('object GetUser')
|
|
110
|
+
expect(result.code).not.toContain('object WatchUsers')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ScopeGroup } from '../../group-routes.js'
|
|
2
|
+
import type { KotlinEmitter } from './ajsc-adapter.js'
|
|
3
|
+
import { emitKotlinRoute, type EmitRouteOpts } from './emit-route-kotlin.js'
|
|
4
|
+
import { kotlinPackageDecl, kotlinSourceHashHeader, kotlinImports, indent, pickDefined } from './format-kotlin.js'
|
|
5
|
+
|
|
6
|
+
export interface EmitScopeOptions extends EmitRouteOpts {
|
|
7
|
+
kotlinPackage: string
|
|
8
|
+
sourceHash: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface EmittedKotlinFile {
|
|
12
|
+
filename: string
|
|
13
|
+
code: string
|
|
14
|
+
/** Names of stream routes within this scope that were skipped. */
|
|
15
|
+
skippedStreams: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pascalCase(scope: string): string {
|
|
19
|
+
return scope
|
|
20
|
+
.split('-')
|
|
21
|
+
.filter((p) => p.length > 0)
|
|
22
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
23
|
+
.join('')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function emitKotlinScope(
|
|
27
|
+
group: ScopeGroup,
|
|
28
|
+
opts: EmitScopeOptions,
|
|
29
|
+
emitter: KotlinEmitter,
|
|
30
|
+
errorSchemas: Map<string, unknown>,
|
|
31
|
+
): EmittedKotlinFile {
|
|
32
|
+
const scopeName = pascalCase(group.scopeKey)
|
|
33
|
+
const allImports: string[] = []
|
|
34
|
+
const routeBlocks: string[] = []
|
|
35
|
+
const skippedStreams: string[] = []
|
|
36
|
+
|
|
37
|
+
const PASSTHROUGH_KEYS = ['serializer', 'unsupportedUnions', 'arrayItemNaming', 'depluralize', 'uncountableWords'] as const
|
|
38
|
+
const routeOpts: EmitRouteOpts = pickDefined(opts, PASSTHROUGH_KEYS)
|
|
39
|
+
|
|
40
|
+
for (const route of group.routes) {
|
|
41
|
+
const r = emitKotlinRoute(route, emitter, errorSchemas, routeOpts)
|
|
42
|
+
if (r.skipped) {
|
|
43
|
+
skippedStreams.push(r.routeName)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
allImports.push(...r.imports)
|
|
47
|
+
const wrapped = `object ${r.routeName} {\n${indent(r.code, 1)}\n}`
|
|
48
|
+
routeBlocks.push(wrapped)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const innerScope = routeBlocks.length === 0 ? '' : indent(routeBlocks.join('\n\n'), 1)
|
|
52
|
+
const scopeBlock = innerScope === ''
|
|
53
|
+
? `object ${scopeName} {\n}`
|
|
54
|
+
: `object ${scopeName} {\n${innerScope}\n}`
|
|
55
|
+
|
|
56
|
+
const importsBlock = kotlinImports(allImports)
|
|
57
|
+
const parts = [
|
|
58
|
+
kotlinPackageDecl(opts.kotlinPackage),
|
|
59
|
+
kotlinSourceHashHeader(opts.sourceHash),
|
|
60
|
+
importsBlock,
|
|
61
|
+
scopeBlock,
|
|
62
|
+
].filter((p) => p.length > 0)
|
|
63
|
+
|
|
64
|
+
return { filename: `${scopeName}.kt`, code: parts.join('\n\n') + '\n', skippedStreams }
|
|
65
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
kotlinPackageDecl,
|
|
4
|
+
kotlinSourceHashHeader,
|
|
5
|
+
kotlinImports,
|
|
6
|
+
indent,
|
|
7
|
+
pickDefined,
|
|
8
|
+
} from './format-kotlin.js'
|
|
9
|
+
|
|
10
|
+
describe('format-kotlin', () => {
|
|
11
|
+
it('emits a package declaration', () => {
|
|
12
|
+
expect(kotlinPackageDecl('com.example.api')).toBe('package com.example.api')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('emits a source-hash header line', () => {
|
|
16
|
+
expect(kotlinSourceHashHeader('abc123')).toBe('// Source hash: abc123')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('dedupes and sorts imports', () => {
|
|
20
|
+
expect(kotlinImports(['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.Serializable'])).toBe(
|
|
21
|
+
'import kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable',
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns empty string when no imports', () => {
|
|
26
|
+
expect(kotlinImports([])).toBe('')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('indents every line by 4 spaces per level', () => {
|
|
30
|
+
expect(indent('a\nb', 1)).toBe(' a\n b')
|
|
31
|
+
expect(indent('a', 2)).toBe(' a')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('preserves blank lines without trailing whitespace when indenting', () => {
|
|
35
|
+
expect(indent('a\n\nb', 1)).toBe(' a\n\n b')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('pickDefined', () => {
|
|
40
|
+
it('returns only the keys whose values are not undefined', () => {
|
|
41
|
+
expect(pickDefined({ a: 1, b: undefined, c: 3 } as { a?: number; b?: number; c?: number }, ['a', 'b', 'c']))
|
|
42
|
+
.toEqual({ a: 1, c: 3 })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('preserves the literal `false` value for boolean opts', () => {
|
|
46
|
+
expect(pickDefined({ depluralize: false, x: undefined } as { depluralize?: boolean; x?: number }, ['depluralize', 'x']))
|
|
47
|
+
.toEqual({ depluralize: false })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('preserves the literal `false` for `string | false` opts (e.g. arrayItemNaming)', () => {
|
|
51
|
+
type Opts = { arrayItemNaming?: string | false }
|
|
52
|
+
const result = pickDefined<Opts, keyof Opts>({ arrayItemNaming: false }, ['arrayItemNaming'])
|
|
53
|
+
expect(result).toEqual({ arrayItemNaming: false })
|
|
54
|
+
expect('arrayItemNaming' in result).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('omits keys not in the keys list even if they are defined on src', () => {
|
|
58
|
+
expect(pickDefined({ a: 1, b: 2 } as { a?: number; b?: number }, ['a']))
|
|
59
|
+
.toEqual({ a: 1 })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns an empty object when all keys are undefined', () => {
|
|
63
|
+
expect(pickDefined({ a: undefined, b: undefined } as { a?: number; b?: number }, ['a', 'b']))
|
|
64
|
+
.toEqual({})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns an empty object when keys is empty', () => {
|
|
68
|
+
expect(pickDefined({ a: 1 }, [] as never[])).toEqual({})
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function kotlinPackageDecl(pkg: string): string {
|
|
2
|
+
return `package ${pkg}`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function kotlinSourceHashHeader(hash: string): string {
|
|
6
|
+
return `// Source hash: ${hash}`
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function kotlinImports(imports: string[]): string {
|
|
10
|
+
if (imports.length === 0) return ''
|
|
11
|
+
const unique = Array.from(new Set(imports)).sort()
|
|
12
|
+
return unique.map((i) => `import ${i}`).join('\n')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function indent(text: string, level: number): string {
|
|
16
|
+
const prefix = ' '.repeat(level)
|
|
17
|
+
return text
|
|
18
|
+
.split('\n')
|
|
19
|
+
.map((line) => (line.length === 0 ? line : `${prefix}${line}`))
|
|
20
|
+
.join('\n')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns a new object containing only the keys of `src` whose values are
|
|
25
|
+
* not `undefined`. Useful for building option objects where unset keys must
|
|
26
|
+
* be ABSENT (not `undefined`-valued), so they don't shadow downstream
|
|
27
|
+
* defaults — e.g., when forwarding into `KotlinEmitOptions`.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* pickDefined({ a: 1, b: undefined, c: false }, ['a', 'b', 'c'])
|
|
31
|
+
* // → { a: 1, c: false }
|
|
32
|
+
*/
|
|
33
|
+
export function pickDefined<T extends object, K extends keyof T>(
|
|
34
|
+
src: T,
|
|
35
|
+
keys: readonly K[],
|
|
36
|
+
): Partial<Pick<T, K>> {
|
|
37
|
+
const out: Partial<Pick<T, K>> = {}
|
|
38
|
+
for (const key of keys) {
|
|
39
|
+
const value = src[key]
|
|
40
|
+
if (value !== undefined) {
|
|
41
|
+
out[key] = value
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { runPipeline } from '../../pipeline.js'
|
|
6
|
+
import { assertGoldenOrUpdate } from '../../test-helpers/golden.js'
|
|
7
|
+
import { createStubKotlinEmitter, type KotlinEmitResult } from './ajsc-adapter.js'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = dirname(__filename)
|
|
11
|
+
|
|
12
|
+
const ok = (code: string, rootTypeName: string, imports: string[] = ['kotlinx.serialization.Serializable']): KotlinEmitResult => ({
|
|
13
|
+
code,
|
|
14
|
+
rootTypeName,
|
|
15
|
+
extractedTypeNames: [],
|
|
16
|
+
imports,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('kotlin codegen — integration', () => {
|
|
20
|
+
it('produces byte-identical output against the golden fixture', async () => {
|
|
21
|
+
const envelopePath = join(__dirname, '__fixtures__/users-envelope.json')
|
|
22
|
+
const goldenPath = join(__dirname, '__fixtures__/users-golden.kt')
|
|
23
|
+
const envelope = JSON.parse(await readFile(envelopePath, 'utf8'))
|
|
24
|
+
|
|
25
|
+
// Hand-authored slot outputs in the v7.2 nested-class shape (inlineTypes: true).
|
|
26
|
+
//
|
|
27
|
+
// The stub map is keyed on rootTypeName, NOT on (route, slot). Both GetUser
|
|
28
|
+
// and CreateUser have a slot named "Response" — they intentionally share the
|
|
29
|
+
// single stub entry below. The golden file therefore shows three identical
|
|
30
|
+
// Response data classes (one per route that has one). This is correct: the
|
|
31
|
+
// integration test pins our file-assembly logic, not real ajsc per-route
|
|
32
|
+
// output. The kotlinc E2E (Task 11) exercises real ajsc against the same
|
|
33
|
+
// fixture and would surface any incompatibility.
|
|
34
|
+
const emitter = createStubKotlinEmitter({
|
|
35
|
+
// GetUser
|
|
36
|
+
PathParams: ok('@Serializable\ndata class PathParams(\n val id: String,\n)', 'PathParams'),
|
|
37
|
+
Response: ok(
|
|
38
|
+
'@Serializable\ndata class Response(\n val id: String,\n val name: String,\n @SerialName("created-at") @Contextual val createdAt: java.time.Instant,\n val address: Address,\n) {\n @Serializable\n data class Address(\n val street: String,\n val city: String,\n )\n}',
|
|
39
|
+
'Response',
|
|
40
|
+
['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.Contextual'],
|
|
41
|
+
),
|
|
42
|
+
NotFound: ok(
|
|
43
|
+
'@Serializable\ndata class NotFound(\n val name: String = "NotFound",\n val message: String,\n)',
|
|
44
|
+
'NotFound',
|
|
45
|
+
),
|
|
46
|
+
|
|
47
|
+
// CreateUser
|
|
48
|
+
Body: ok(
|
|
49
|
+
'@Serializable\n@JsonClassDiscriminator("kind")\nsealed interface Body {\n @Serializable\n @SerialName("guest")\n data class GuestBody(\n val displayName: String,\n ) : Body\n\n @Serializable\n @SerialName("registered")\n data class RegisteredBody(\n val email: String,\n val name: String,\n ) : Body\n}',
|
|
50
|
+
'Body',
|
|
51
|
+
['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName', 'kotlinx.serialization.json.JsonClassDiscriminator'],
|
|
52
|
+
),
|
|
53
|
+
ValidationError: ok(
|
|
54
|
+
'@Serializable\ndata class ValidationError(\n val name: String = "ValidationError",\n val message: String,\n val field: String? = null,\n)',
|
|
55
|
+
'ValidationError',
|
|
56
|
+
),
|
|
57
|
+
|
|
58
|
+
// ListUsers
|
|
59
|
+
Query: ok(
|
|
60
|
+
'@Serializable\ndata class Query(\n val status: Status? = null,\n val limit: Long? = null,\n) {\n @Serializable\n enum class Status {\n @SerialName("active") ACTIVE,\n @SerialName("inactive") INACTIVE,\n }\n}',
|
|
61
|
+
'Query',
|
|
62
|
+
['kotlinx.serialization.Serializable', 'kotlinx.serialization.SerialName'],
|
|
63
|
+
),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const files = await runPipeline({
|
|
67
|
+
envelope, outDir: 'out', dryRun: true,
|
|
68
|
+
target: 'kotlin', kotlinPackage: 'com.example.api',
|
|
69
|
+
kotlinEmitter: emitter,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
expect(files).toHaveLength(1)
|
|
73
|
+
expect(files[0]!.path).toBe(join('out', 'Users.kt'))
|
|
74
|
+
|
|
75
|
+
await assertGoldenOrUpdate(files[0]!.code, goldenPath)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
let ajscResolvable = false
|
|
4
|
+
let emitKotlinFn:
|
|
5
|
+
| ((
|
|
6
|
+
schema: unknown,
|
|
7
|
+
opts: unknown,
|
|
8
|
+
) => { code: string; imports: string[]; rootTypeName: string; extractedTypeNames: string[] })
|
|
9
|
+
| undefined
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const ajsc = await import('ajsc')
|
|
13
|
+
if (typeof (ajsc as { emitKotlin?: unknown }).emitKotlin === 'function') {
|
|
14
|
+
ajscResolvable = true
|
|
15
|
+
emitKotlinFn = (ajsc as { emitKotlin: typeof emitKotlinFn }).emitKotlin!
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// ajsc not installed (e.g. npm install --omit=optional); test skips below.
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('ajsc.emitKotlin — untagged oneOf behavior', () => {
|
|
22
|
+
it.skipIf(!ajscResolvable)(
|
|
23
|
+
'produces a deterministic fallback shape for an untagged oneOf',
|
|
24
|
+
() => {
|
|
25
|
+
const schema = {
|
|
26
|
+
oneOf: [{ type: 'string' }, { type: 'integer' }],
|
|
27
|
+
}
|
|
28
|
+
const result = emitKotlinFn!(schema, {
|
|
29
|
+
rootTypeName: 'Mixed',
|
|
30
|
+
inlineTypes: true,
|
|
31
|
+
unsupportedUnions: 'fallback',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Snapshot pins the current ajsc behavior. If ajsc changes the fallback
|
|
35
|
+
// shape, this diff prompts an intentional review and a corresponding
|
|
36
|
+
// update to docs/codegen-kotlin.md.
|
|
37
|
+
expect({
|
|
38
|
+
code: result.code,
|
|
39
|
+
imports: result.imports.slice().sort(),
|
|
40
|
+
rootTypeName: result.rootTypeName,
|
|
41
|
+
extractedTypeNames: result.extractedTypeNames.slice().sort(),
|
|
42
|
+
}).toMatchSnapshot()
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// Companion to test 1: confirms 'fallback' is not load-bearing — ajsc emits the
|
|
47
|
+
// same shape with or without it. If these snapshots ever diverge, that's a
|
|
48
|
+
// meaningful behavior change worth surfacing in docs/codegen-kotlin.md.
|
|
49
|
+
it.skipIf(!ajscResolvable)(
|
|
50
|
+
'silently falls back to empty data class when unsupportedUnions is not specified',
|
|
51
|
+
() => {
|
|
52
|
+
const schema = { oneOf: [{ type: 'string' }, { type: 'integer' }] }
|
|
53
|
+
const result = emitKotlinFn!(schema, { rootTypeName: 'Mixed', inlineTypes: true })
|
|
54
|
+
// When unsupportedUnions defaults (not explicitly set), ajsc does NOT throw.
|
|
55
|
+
// Instead it silently emits an empty data class.
|
|
56
|
+
expect({
|
|
57
|
+
code: result.code,
|
|
58
|
+
imports: result.imports.slice().sort(),
|
|
59
|
+
rootTypeName: result.rootTypeName,
|
|
60
|
+
extractedTypeNames: result.extractedTypeNames.slice().sort(),
|
|
61
|
+
}).toMatchSnapshot()
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { assertGoldenOrUpdate } from './golden.js'
|
|
6
|
+
|
|
7
|
+
let tmpDir: string | undefined
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
if (tmpDir != null) {
|
|
11
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
12
|
+
tmpDir = undefined
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function makeTmp(): string {
|
|
17
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'tsp-golden-'))
|
|
18
|
+
return tmpDir
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('assertGoldenOrUpdate', () => {
|
|
22
|
+
it('asserts byte-equality against golden when produced matches (with hash splice)', async () => {
|
|
23
|
+
const dir = makeTmp()
|
|
24
|
+
const goldenPath = join(dir, 'expected.txt')
|
|
25
|
+
writeFileSync(goldenPath, 'hello\n// Source hash: <PLACEHOLDER>\nworld\n', 'utf-8')
|
|
26
|
+
|
|
27
|
+
const produced = 'hello\n// Source hash: abc123def456\nworld\n'
|
|
28
|
+
await expect(assertGoldenOrUpdate(produced, goldenPath)).resolves.toBeUndefined()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('throws when produced does not match golden', async () => {
|
|
32
|
+
const dir = makeTmp()
|
|
33
|
+
const goldenPath = join(dir, 'expected.txt')
|
|
34
|
+
writeFileSync(goldenPath, 'expected content\n', 'utf-8')
|
|
35
|
+
|
|
36
|
+
const produced = 'different content\n'
|
|
37
|
+
await expect(assertGoldenOrUpdate(produced, goldenPath)).rejects.toThrow()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('writes a portable golden when UPDATE_GOLDENS=1', async () => {
|
|
41
|
+
const original = process.env.UPDATE_GOLDENS
|
|
42
|
+
process.env.UPDATE_GOLDENS = '1'
|
|
43
|
+
try {
|
|
44
|
+
const dir = makeTmp()
|
|
45
|
+
const goldenPath = join(dir, 'expected.txt')
|
|
46
|
+
const produced = 'hello\n// Source hash: deadbeef1234\nworld\n'
|
|
47
|
+
await assertGoldenOrUpdate(produced, goldenPath)
|
|
48
|
+
const written = readFileSync(goldenPath, 'utf-8')
|
|
49
|
+
expect(written).toBe('hello\n// Source hash: <PLACEHOLDER>\nworld\n')
|
|
50
|
+
} finally {
|
|
51
|
+
if (original === undefined) delete process.env.UPDATE_GOLDENS
|
|
52
|
+
else process.env.UPDATE_GOLDENS = original
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('handles produced output without a source-hash line', async () => {
|
|
57
|
+
const dir = makeTmp()
|
|
58
|
+
const goldenPath = join(dir, 'expected.txt')
|
|
59
|
+
writeFileSync(goldenPath, 'plain content\n', 'utf-8')
|
|
60
|
+
|
|
61
|
+
// No source hash; splice is a no-op on both sides.
|
|
62
|
+
await expect(assertGoldenOrUpdate('plain content\n', goldenPath)).resolves.toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('regenerate mode handles produced output without a source-hash line', async () => {
|
|
66
|
+
const original = process.env.UPDATE_GOLDENS
|
|
67
|
+
process.env.UPDATE_GOLDENS = '1'
|
|
68
|
+
try {
|
|
69
|
+
const dir = makeTmp()
|
|
70
|
+
const goldenPath = join(dir, 'expected.txt')
|
|
71
|
+
const produced = 'no hash line here\n'
|
|
72
|
+
await assertGoldenOrUpdate(produced, goldenPath)
|
|
73
|
+
const written = readFileSync(goldenPath, 'utf-8')
|
|
74
|
+
expect(written).toBe('no hash line here\n')
|
|
75
|
+
} finally {
|
|
76
|
+
if (original === undefined) delete process.env.UPDATE_GOLDENS
|
|
77
|
+
else process.env.UPDATE_GOLDENS = original
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { expect } from 'vitest'
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compares produced source against a golden file, with optional regeneration.
|
|
6
|
+
*
|
|
7
|
+
* - Normal mode: reads `goldenPath`, splices the produced source-hash line into
|
|
8
|
+
* the golden's `<PLACEHOLDER>` slot, and asserts byte-equality.
|
|
9
|
+
* - Regen mode (`UPDATE_GOLDENS=1`): replaces the produced source-hash line with
|
|
10
|
+
* `<PLACEHOLDER>` and writes the result to `goldenPath`. The next normal run
|
|
11
|
+
* will assert against the new golden.
|
|
12
|
+
*
|
|
13
|
+
* The source-hash placeholder lets us check generated content into source
|
|
14
|
+
* control without coupling the golden to a specific envelope hash. Codegen
|
|
15
|
+
* outputs that don't include a `// Source hash:` line work too — the
|
|
16
|
+
* splice/replace is a no-op when no match is found.
|
|
17
|
+
*/
|
|
18
|
+
export async function assertGoldenOrUpdate(produced: string, goldenPath: string): Promise<void> {
|
|
19
|
+
if (process.env.UPDATE_GOLDENS === '1') {
|
|
20
|
+
const goldenContent = produced.replace(
|
|
21
|
+
/^\/\/ Source hash: [a-f0-9]+$/m,
|
|
22
|
+
'// Source hash: <PLACEHOLDER>',
|
|
23
|
+
)
|
|
24
|
+
await writeFile(goldenPath, goldenContent, 'utf-8')
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.log(`[golden-test] Wrote golden: ${goldenPath}`)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const goldenTemplate = await readFile(goldenPath, 'utf8')
|
|
31
|
+
const sourceHashLine = produced.split('\n').find((l) => l.startsWith('// Source hash:')) ?? ''
|
|
32
|
+
const goldenWithHash = goldenTemplate.replace('// Source hash: <PLACEHOLDER>', sourceHashLine)
|
|
33
|
+
expect(produced).toBe(goldenWithHash)
|
|
34
|
+
}
|