ts-procedures 6.0.2 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent_config/bin/setup.mjs +2 -2
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -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/claude-code/skills/ts-procedures-swift/SKILL.md +119 -0
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/agent_config/lib/install-claude.mjs +1 -1
- package/build/codegen/bin/cli.d.ts +39 -0
- package/build/codegen/bin/cli.js +164 -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 +36 -0
- package/build/codegen/index.js +8 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +22 -4
- package/build/codegen/pipeline.js +44 -86
- 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/_shared/error-schemas.d.ts +10 -0
- package/build/codegen/targets/_shared/error-schemas.js +17 -0
- package/build/codegen/targets/_shared/error-schemas.js.map +1 -0
- package/build/codegen/targets/_shared/error-schemas.test.d.ts +1 -0
- package/build/codegen/targets/_shared/error-schemas.test.js +38 -0
- package/build/codegen/targets/_shared/error-schemas.test.js.map +1 -0
- package/build/codegen/targets/_shared/indent.d.ts +6 -0
- package/build/codegen/targets/_shared/indent.js +13 -0
- package/build/codegen/targets/_shared/indent.js.map +1 -0
- package/build/codegen/targets/_shared/indent.test.d.ts +1 -0
- package/build/codegen/targets/_shared/indent.test.js +21 -0
- package/build/codegen/targets/_shared/indent.test.js.map +1 -0
- package/build/codegen/targets/_shared/pascal-case.d.ts +6 -0
- package/build/codegen/targets/_shared/pascal-case.js +13 -0
- package/build/codegen/targets/_shared/pascal-case.js.map +1 -0
- package/build/codegen/targets/_shared/pascal-case.test.d.ts +1 -0
- package/build/codegen/targets/_shared/pascal-case.test.js +25 -0
- package/build/codegen/targets/_shared/pascal-case.test.js.map +1 -0
- package/build/codegen/targets/_shared/path-utils.d.ts +12 -0
- package/build/codegen/targets/_shared/path-utils.js +20 -0
- package/build/codegen/targets/_shared/path-utils.js.map +1 -0
- package/build/codegen/targets/_shared/path-utils.test.d.ts +1 -0
- package/build/codegen/targets/_shared/path-utils.test.js +42 -0
- package/build/codegen/targets/_shared/path-utils.test.js.map +1 -0
- package/build/codegen/targets/_shared/pick-defined.d.ts +11 -0
- package/build/codegen/targets/_shared/pick-defined.js +21 -0
- package/build/codegen/targets/_shared/pick-defined.js.map +1 -0
- package/build/codegen/targets/_shared/pick-defined.test.d.ts +1 -0
- package/build/codegen/targets/_shared/pick-defined.test.js +25 -0
- package/build/codegen/targets/_shared/pick-defined.test.js.map +1 -0
- package/build/codegen/targets/_shared/route-slots.d.ts +17 -0
- package/build/codegen/targets/_shared/route-slots.js +17 -0
- package/build/codegen/targets/_shared/route-slots.js.map +1 -0
- package/build/codegen/targets/_shared/route-slots.test.d.ts +1 -0
- package/build/codegen/targets/_shared/route-slots.test.js +43 -0
- package/build/codegen/targets/_shared/route-slots.test.js.map +1 -0
- package/build/codegen/targets/_shared/target-run.d.ts +27 -0
- package/build/codegen/targets/_shared/target-run.js +2 -0
- package/build/codegen/targets/_shared/target-run.js.map +1 -0
- package/build/codegen/targets/_shared/write-files.d.ts +24 -0
- package/build/codegen/targets/_shared/write-files.js +35 -0
- package/build/codegen/targets/_shared/write-files.js.map +1 -0
- package/build/codegen/targets/_shared/write-files.test.d.ts +1 -0
- package/build/codegen/targets/_shared/write-files.test.js +79 -0
- package/build/codegen/targets/_shared/write-files.test.js.map +1 -0
- 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 -28
- 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 +12 -11
- 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 +0 -1
- package/build/codegen/targets/kotlin/format-kotlin.js +0 -7
- package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
- package/build/codegen/targets/kotlin/format-kotlin.test.js +1 -8
- package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
- package/build/codegen/targets/kotlin/integration.test.js +27 -10
- 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/targets/kotlin/run.d.ts +11 -0
- package/build/codegen/targets/kotlin/run.js +51 -0
- package/build/codegen/targets/kotlin/run.js.map +1 -0
- package/build/codegen/targets/swift/access-level.test.d.ts +1 -0
- package/build/codegen/targets/swift/access-level.test.js +98 -0
- package/build/codegen/targets/swift/access-level.test.js.map +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.d.ts +27 -0
- package/build/codegen/targets/swift/ajsc-adapter.js +38 -0
- package/build/codegen/targets/swift/ajsc-adapter.js.map +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.d.ts +1 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.js +37 -0
- package/build/codegen/targets/swift/ajsc-adapter.test.js.map +1 -0
- package/build/codegen/targets/swift/e2e-compile.test.d.ts +1 -0
- package/build/codegen/targets/swift/e2e-compile.test.js +57 -0
- package/build/codegen/targets/swift/e2e-compile.test.js.map +1 -0
- package/build/codegen/targets/swift/emit-route-swift.d.ts +15 -0
- package/build/codegen/targets/swift/emit-route-swift.js +64 -0
- package/build/codegen/targets/swift/emit-route-swift.js.map +1 -0
- package/build/codegen/targets/swift/emit-route-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/emit-route-swift.test.js +258 -0
- package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.d.ts +13 -0
- package/build/codegen/targets/swift/emit-scope-swift.js +36 -0
- package/build/codegen/targets/swift/emit-scope-swift.js.map +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.js +136 -0
- package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/format-swift.d.ts +2 -0
- package/build/codegen/targets/swift/format-swift.js +10 -0
- package/build/codegen/targets/swift/format-swift.js.map +1 -0
- package/build/codegen/targets/swift/format-swift.test.d.ts +1 -0
- package/build/codegen/targets/swift/format-swift.test.js +14 -0
- package/build/codegen/targets/swift/format-swift.test.js.map +1 -0
- package/build/codegen/targets/swift/integration.test.d.ts +1 -0
- package/build/codegen/targets/swift/integration.test.js +53 -0
- package/build/codegen/targets/swift/integration.test.js.map +1 -0
- package/build/codegen/targets/swift/run.d.ts +11 -0
- package/build/codegen/targets/swift/run.js +47 -0
- package/build/codegen/targets/swift/run.js.map +1 -0
- package/build/codegen/targets/ts/run.d.ts +4 -0
- package/build/codegen/targets/ts/run.js +86 -0
- package/build/codegen/targets/ts/run.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 +176 -0
- package/docs/codegen-swift.md +314 -0
- package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
- package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +1 -1
- package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
- package/docs/superpowers/specs/2026-04-25-swift-codegen-design.md +264 -0
- package/package.json +2 -2
- package/src/codegen/__fixtures__/users-envelope.json +144 -0
- package/src/codegen/bin/cli.test.ts +200 -1
- package/src/codegen/bin/cli.ts +187 -0
- package/src/codegen/index.ts +50 -0
- package/src/codegen/pipeline.test.ts +175 -0
- package/src/codegen/pipeline.ts +58 -101
- package/src/codegen/targets/_shared/error-schemas.test.ts +42 -0
- package/src/codegen/targets/_shared/error-schemas.ts +17 -0
- package/src/codegen/targets/_shared/indent.test.ts +25 -0
- package/src/codegen/targets/_shared/indent.ts +12 -0
- package/src/codegen/targets/_shared/pascal-case.test.ts +30 -0
- package/src/codegen/targets/_shared/pascal-case.ts +12 -0
- package/src/codegen/targets/_shared/path-utils.test.ts +51 -0
- package/src/codegen/targets/_shared/path-utils.ts +21 -0
- package/src/codegen/targets/_shared/pick-defined.test.ts +48 -0
- package/src/codegen/targets/_shared/pick-defined.ts +23 -0
- package/src/codegen/targets/_shared/route-slots.test.ts +55 -0
- package/src/codegen/targets/_shared/route-slots.ts +32 -0
- package/src/codegen/targets/_shared/target-run.ts +28 -0
- package/src/codegen/targets/_shared/write-files.test.ts +110 -0
- package/src/codegen/targets/_shared/write-files.ts +53 -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 +89 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
- package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +60 -0
- package/src/codegen/targets/kotlin/format-kotlin.test.ts +26 -0
- package/src/codegen/targets/kotlin/format-kotlin.ts +13 -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/targets/kotlin/run.ts +78 -0
- package/src/codegen/targets/swift/__fixtures__/users-golden.swift +123 -0
- package/src/codegen/targets/swift/access-level.test.ts +108 -0
- package/src/codegen/targets/swift/ajsc-adapter.test.ts +47 -0
- package/src/codegen/targets/swift/ajsc-adapter.ts +67 -0
- package/src/codegen/targets/swift/e2e-compile.test.ts +66 -0
- package/src/codegen/targets/swift/emit-route-swift.test.ts +300 -0
- package/src/codegen/targets/swift/emit-route-swift.ts +90 -0
- package/src/codegen/targets/swift/emit-scope-swift.test.ts +164 -0
- package/src/codegen/targets/swift/emit-scope-swift.ts +59 -0
- package/src/codegen/targets/swift/format-swift.test.ts +23 -0
- package/src/codegen/targets/swift/format-swift.ts +9 -0
- package/src/codegen/targets/swift/integration.test.ts +80 -0
- package/src/codegen/targets/swift/run.ts +74 -0
- package/src/codegen/targets/ts/run.ts +117 -0
- package/src/codegen/test-helpers/golden.test.ts +80 -0
- package/src/codegen/test-helpers/golden.ts +34 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# ajsc v7.2 Kotlin Codegen Polish
|
|
2
|
+
|
|
3
|
+
**Status:** Shipped
|
|
4
|
+
**Date:** 2026-04-25
|
|
5
|
+
**Author:** Cory Robinson
|
|
6
|
+
**Supersedes parts of:** [`2026-04-24-kotlin-swift-codegen-design.md`](./2026-04-24-kotlin-swift-codegen-design.md) — Kotlin sections only. Swift sections of the prior design remain in force and unscheduled.
|
|
7
|
+
**Scope:** Polish the existing `codegen-kotlin-swift` branch's Kotlin target to align with `ajsc@^7.2.0`. No Swift work in this round.
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The original Kotlin codegen design ([2026-04-24](./2026-04-24-kotlin-swift-codegen-design.md)) anticipated an "ajsc Phase A" delivery exposing `emitKotlin`. ajsc shipped that and more in `7.0` → `7.1` → `7.2`:
|
|
12
|
+
|
|
13
|
+
- **`7.0`** delivered `emitKotlin` and `emitSwift` with the `EmitResult` shape the prior spec specified.
|
|
14
|
+
- **`7.1`** added `nameRegistry: Set<string>` + `namePrefix: string` to make cross-call dedup tractable when concatenating multiple slot emissions into one namespace.
|
|
15
|
+
- **`7.2`** added `inlineTypes?: boolean` (default `false`). When `true`, nested object types emit as **nested classes inside their parent's body** instead of extracting to top-level siblings. Kotlin's nested-class scoping makes `Body.Address` and `Response.Address` different fully-qualified names — collisions become structurally impossible without any shared state.
|
|
16
|
+
|
|
17
|
+
The current branch implementation predates `7.1` and `7.2`. It calls `emitKotlin` once per slot with no shared registry, which is a correctness bug for any schema that has structurally-identical sub-objects in two slots (Kotlin emits `data class Address(...)` from both calls → duplicate-class compile error).
|
|
18
|
+
|
|
19
|
+
This polish round adopts `inlineTypes: true` as the routing strategy, drops dead options from our adapter contract, surfaces `serializer` and `unsupportedUnions` as CLI flags, replaces the trivial test fixture with a realistic one, activates the gated `kotlinc` E2E test, and ships a downstream-consumer setup guide.
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
1. **Correctness**: generated Kotlin compiles for realistic schemas (shared sub-types across slots, discriminated unions, `format: date-time` fields, JSON-key sanitization).
|
|
24
|
+
2. **Clean separation of concerns**: ajsc emits declarations; `ts-procedures` wraps and writes files. No shared state across `emit` calls in our code.
|
|
25
|
+
3. **Easy-to-follow code**: route emitter is a pure function — same inputs, same output, no per-call coordination logic.
|
|
26
|
+
4. **Strong downstream DX**: a typed Android/iOS dev integrating generated output gets a clear setup guide covering Gradle deps, contextual serializers, sealed-interface decoding, and the `serializer: 'none'` escape hatch.
|
|
27
|
+
5. **Forward-compat**: changes are additive on the CLI surface and conservative on defaults so future Swift work and future ajsc releases land cleanly.
|
|
28
|
+
|
|
29
|
+
## Non-goals
|
|
30
|
+
|
|
31
|
+
- **Swift target.** Stays deferred. The architecture sketched here generalizes naturally — same per-slot loop, same `inlineTypes: true` strategy — but a separate spec will cover Swift-specific idioms (`Codable`, `AnyCodable` fallback shape, `accessLevel`).
|
|
32
|
+
- **Cross-route shared types.** No `Shared.User` extraction. Each route's types live under its own `object`.
|
|
33
|
+
- **Streaming routes.** Skipped with a single summary log line, as in the original spec.
|
|
34
|
+
- **Hooks, per-call options, error registry, base-URL handling.** All deferred per the prior spec; nothing changes.
|
|
35
|
+
- **Mid-PR changes to TS target output.** The TS codegen path is untouched.
|
|
36
|
+
- **`agent_config/` distribution updates.** Codegen targets are not agent-relevant content.
|
|
37
|
+
|
|
38
|
+
## Architecture & boundary
|
|
39
|
+
|
|
40
|
+
The contract between ajsc and `ts-procedures` is unchanged from the original spec: ajsc emits **declarations only** (no `package`, no `import` lines); `ts-procedures` assembles the file (`package` line, deduped imports, scope/route wrappers, source-hash header).
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
DocEnvelope
|
|
44
|
+
→ resolveEnvelope
|
|
45
|
+
→ groupRoutesByScope
|
|
46
|
+
→ for each scope:
|
|
47
|
+
→ for each route:
|
|
48
|
+
→ for each slot in [pathParams, query, body, response, ...errors]:
|
|
49
|
+
emitKotlin(slotSchema, { rootTypeName: slot.name, inlineTypes: true, serializer, unsupportedUnions, ...passthrough })
|
|
50
|
+
→ wrap collected emissions in `object RouteName { method, pathTemplate, path(...) | const path, ...slots, Errors? }`
|
|
51
|
+
→ wrap routes in `object Scope { ... }`
|
|
52
|
+
→ prepend package + source-hash + imports
|
|
53
|
+
→ write Scope.kt
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Two architectural decisions:
|
|
57
|
+
|
|
58
|
+
- **`packageName` opt is not passed to ajsc.** The scope emitter writes `package com.example.api` itself. Keeps the boundary "ajsc gives declarations, we assemble files" intact and avoids any (untested) interaction of v7.2's `packageName` opt with the `code` body.
|
|
59
|
+
- **No shared state across emit calls.** Per-route registry plumbing (`nameRegistry` / `namePrefix`) is **not** used. `inlineTypes: true` makes it unnecessary because nested-class scoping is structural.
|
|
60
|
+
|
|
61
|
+
## Adapter contract — `KotlinEmitOptions` final shape
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export interface KotlinEmitOptions {
|
|
65
|
+
rootTypeName: string // required — slot name (Body, Response, NotFound, ...)
|
|
66
|
+
inlineTypes?: boolean // we always pass true
|
|
67
|
+
serializer?: 'kotlinx' | 'none' // CLI flag, default 'kotlinx'
|
|
68
|
+
unsupportedUnions?: 'throw' | 'fallback' // CLI flag, default 'throw'
|
|
69
|
+
arrayItemNaming?: string | false // pass-through (existing CLI flag)
|
|
70
|
+
depluralize?: boolean // pass-through (existing CLI flag)
|
|
71
|
+
uncountableWords?: string[] // pass-through (existing CLI flag)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Removed from the existing branch's adapter:**
|
|
76
|
+
|
|
77
|
+
- `inlineTypes` previously typed as a knob — now hardcoded `true` (still on the type but never set by callers other than ourselves).
|
|
78
|
+
- `enumStyle` — TypeScript-only ajsc opt; never honored by `KotlinConverterOpts`.
|
|
79
|
+
|
|
80
|
+
**Not used by us (but valid in ajsc):** `nameRegistry`, `namePrefix`, `packageName`, `unsupportedUnions: 'fallback'` for already-decided opt-in flow.
|
|
81
|
+
|
|
82
|
+
The `KotlinEmitter` interface stays `emit(schema, opts) → KotlinEmitResult`. The production resolver collapses to a one-liner:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
return { emit: (schema, opts) => emitKotlin(schema, opts) }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The stub factory (`createStubKotlinEmitter`) used in unit/integration tests stays the same shape — keyed on `rootTypeName`, returns hand-authored `KotlinEmitResult` objects.
|
|
89
|
+
|
|
90
|
+
## Per-route emission
|
|
91
|
+
|
|
92
|
+
`emit-route-kotlin.ts` becomes a pure function — no shared `Set<string>`, no prefix arithmetic:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
emitKotlinRoute(route, emitter, errorSchemas, { serializer, unsupportedUnions, ...passthrough }):
|
|
96
|
+
if route.kind === 'stream' → return { code: '', imports: [], routeName, skipped: true }
|
|
97
|
+
|
|
98
|
+
emit lines:
|
|
99
|
+
`const val method = "<method>"`
|
|
100
|
+
`const val pathTemplate = "<bracePath>"`
|
|
101
|
+
`fun path(p: PathParams): String = "<interpolated>"` if path params present
|
|
102
|
+
`const val path = "<bracePath>"` otherwise
|
|
103
|
+
|
|
104
|
+
for each slot in [pathParams, query, body, response]:
|
|
105
|
+
if slot.schema is null, skip
|
|
106
|
+
result = emitter.emit(slot.schema, { rootTypeName: slot.name, inlineTypes: true, serializer, unsupportedUnions, ...passthrough })
|
|
107
|
+
push result.code, result.imports
|
|
108
|
+
|
|
109
|
+
for each errorKey in route.errors:
|
|
110
|
+
if errorSchemas.has(errorKey):
|
|
111
|
+
result = emitter.emit(errorSchemas.get(errorKey), { rootTypeName: errorKey, inlineTypes: true, serializer, unsupportedUnions, ...passthrough })
|
|
112
|
+
collect into Errors namespace
|
|
113
|
+
if any errors emitted, append wrapped `object Errors { ... }`
|
|
114
|
+
|
|
115
|
+
return { code, imports, routeName, skipped: false }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Slot emit order is fixed: `pathParams → query → body → response → errors[]`. Deterministic output is required for the source-hash staleness check.
|
|
119
|
+
|
|
120
|
+
**Stream-skip:** the route emitter returns `skipped: true`; the pipeline collects names and logs **one summary line** at end of run. No per-route warnings during emission.
|
|
121
|
+
|
|
122
|
+
## Pipeline & CLI
|
|
123
|
+
|
|
124
|
+
### `PipelineOptions` additions
|
|
125
|
+
|
|
126
|
+
| Field | Type | Default | Notes |
|
|
127
|
+
|---|---|---|---|
|
|
128
|
+
| `target` | `'ts' \| 'kotlin'` | `'ts'` | unchanged |
|
|
129
|
+
| `kotlinPackage` | `string` | — | required when `target === 'kotlin'` |
|
|
130
|
+
| `kotlinSerializer` | `'kotlinx' \| 'none'` | `'kotlinx'` | new |
|
|
131
|
+
| `unsupportedUnions` | `'throw' \| 'fallback'` | `'throw'` | new — top-level (not Kotlin-nested) so name stays stable when Swift lands |
|
|
132
|
+
| `kotlinEmitter` | `KotlinEmitter \| undefined` | — | injected for tests; production CLI resolves via `resolveProductionKotlinEmitter()` |
|
|
133
|
+
|
|
134
|
+
### CLI flags
|
|
135
|
+
|
|
136
|
+
| Flag | Maps to | Notes |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `--target <ts\|kotlin>` | `target` | unchanged |
|
|
139
|
+
| `--kotlin-package <pkg>` | `kotlinPackage` | unchanged; required when `target=kotlin` |
|
|
140
|
+
| `--kotlin-serializer <kotlinx\|none>` | `kotlinSerializer` | new |
|
|
141
|
+
| `--unsupported-unions <throw\|fallback>` | `unsupportedUnions` | new |
|
|
142
|
+
| `--array-item-naming`, `--depluralize`, `--uncountable-words` | `KotlinEmitOptions` pass-through | existing TS-target flags, now also applied to Kotlin target |
|
|
143
|
+
|
|
144
|
+
**Silently ignored under `--target ts`:** `--kotlin-serializer`. **Silently ignored under `--target kotlin`:** `--service-name`, `--namespace-types`, `--self-contained`, `--client-import-path`, `--jsdoc` (matches existing behavior).
|
|
145
|
+
|
|
146
|
+
### Config-file mapping
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"target": "kotlin",
|
|
151
|
+
"kotlin": { "package": "com.example.api", "serializer": "kotlinx" },
|
|
152
|
+
"unsupportedUnions": "throw",
|
|
153
|
+
"url": "https://api.example.com/_ts-procedures.json",
|
|
154
|
+
"out": "./generated"
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
CLI flags override config values. `--kotlin-serializer none` works regardless of imports — ajsc decides.
|
|
159
|
+
|
|
160
|
+
### Where parsed values land
|
|
161
|
+
|
|
162
|
+
The existing `parseArgs` returns `ParsedArgs` with `parsed.kotlin.package` for the package flag. The new fields slot in the same way:
|
|
163
|
+
|
|
164
|
+
| CLI flag | Lands at | Rationale |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `--kotlin-serializer` | `parsed.kotlin.serializer` | Kotlin-specific knob — nests under `kotlin` like `package` |
|
|
167
|
+
| `--unsupported-unions` | `parsed.unsupportedUnions` | Top-level — forward-compat for Swift and any future target |
|
|
168
|
+
|
|
169
|
+
### Behavior under `--target ts`
|
|
170
|
+
|
|
171
|
+
| Flag | TS-target behavior |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `--kotlin-serializer` | silently ignored (Kotlin-only knob) |
|
|
174
|
+
| `--unsupported-unions` | **threaded through** to TS emit unconditionally — it is a no-op in ajsc's TypeScript converter today, but threading it preserves forward-compat semantics |
|
|
175
|
+
| `--kotlin-package` | silently ignored |
|
|
176
|
+
|
|
177
|
+
### `--kotlin-inline-types` is intentionally not exposed
|
|
178
|
+
|
|
179
|
+
Hardcoding `inlineTypes: true` is a deliberate design choice. The flat-types pattern (`nameRegistry` + `namePrefix`) remains supported by ajsc; if a consumer demands it later, an opt-out flag (`--kotlin-flat-types`) can be added additively without breaking existing output.
|
|
180
|
+
|
|
181
|
+
## Testing strategy
|
|
182
|
+
|
|
183
|
+
Four layers, each catching a distinct class of breakage. The current branch already has skeletons for three; this round updates assertions to v7.2 reality and adds the fourth (probe).
|
|
184
|
+
|
|
185
|
+
### Realistic fixture (`__fixtures__/users-envelope.json`)
|
|
186
|
+
|
|
187
|
+
Single source of truth for the integration golden and the kotlinc E2E. Replaces the existing `{ type: 'object' }` placeholder envelope. Contains:
|
|
188
|
+
|
|
189
|
+
| Route | Exercises |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `GET /users/:id` (`GetUser`) | path params + nested response object + nested address (two-level nesting → exercises `inlineTypes`) + `format: date-time` field (`@Contextual Instant`) + `created-at` JSON key (auto `@SerialName`) + one error (`NotFound`) |
|
|
192
|
+
| `POST /users` (`CreateUser`) | no path params → `const val path` branch + body with discriminated `oneOf` (sealed interface emission) + one error (`ValidationError`) |
|
|
193
|
+
| `GET /users` (`ListUsers`) | query params + array response + enum field (`status: 'active' \| 'inactive'`) → top-level enum extraction (always extracted regardless of `inlineTypes`) |
|
|
194
|
+
|
|
195
|
+
Plus envelope-level `errors[]` with two error schemas so the route emitter's filter-by-key logic gets real signal.
|
|
196
|
+
|
|
197
|
+
### Layer 1 — Unit tests (revised, unconditional, sub-second)
|
|
198
|
+
|
|
199
|
+
Per-file 1:1 with current layout. Behavioral changes:
|
|
200
|
+
|
|
201
|
+
- **`format-kotlin.test.ts`** — no changes.
|
|
202
|
+
- **`ajsc-adapter.test.ts`** — drop `inlineTypes`/`enumStyle` from contract test; pin error-message text from `resolveProductionKotlinEmitter()` when `import('ajsc')` fails or `emitKotlin` is missing.
|
|
203
|
+
- **`emit-route-kotlin.test.ts`** — drop registry/prefix assertions; add `expect(passedOpts.inlineTypes).toBe(true)` per slot; verify slot ordering; verify stream routes return `skipped: true` and empty code.
|
|
204
|
+
- **`emit-scope-kotlin.test.ts`** — confirm imports deduped/sorted across all routes in a scope; no namespace-shape changes.
|
|
205
|
+
|
|
206
|
+
### Layer 2 — Integration test (golden, stub emitter)
|
|
207
|
+
|
|
208
|
+
`integration.test.ts` runs full `runPipeline` against the realistic fixture with a **stub** emitter returning hand-authored ajsc-style output (nested-class shape) per slot. Asserts byte-identical match against `__fixtures__/users-golden.kt`.
|
|
209
|
+
|
|
210
|
+
**Why stub for integration:** pins our wrapping logic independent of ajsc version drift. If a future ajsc patch tweaks formatting whitespace, the golden test still passes; the kotlinc E2E catches semantic regressions. Separates "did our pipeline assemble the right file?" from "does ajsc still emit valid Kotlin?"
|
|
211
|
+
|
|
212
|
+
Golden regenerated via `UPDATE_GOLDENS=1 npx vitest run integration.test.ts`. Source-hash line spliced from actual run.
|
|
213
|
+
|
|
214
|
+
### Layer 3 — Kotlinc E2E compile test
|
|
215
|
+
|
|
216
|
+
`e2e-compile.test.ts` runs the pipeline with the **production** ajsc emitter against the realistic fixture, writes output to a tmp dir, invokes `kotlinc` with `kotlinx-serialization-json` on classpath, asserts exit 0.
|
|
217
|
+
|
|
218
|
+
**Gating:** `it.skipIf(!kotlincAvailable() || process.env.TS_PROCEDURES_KOTLIN_E2E !== '1')`. The ajsc Phase A check is removed — v7.2 is installed.
|
|
219
|
+
|
|
220
|
+
**What this catches that nothing else does:** silent name-collision regressions, schema patterns producing uncompilable Kotlin (reserved-word edges), missing `@Contextual` annotations, sealed-interface scoping bugs.
|
|
221
|
+
|
|
222
|
+
### Layer 4 — Probe test for `unsupportedUnions: 'fallback'` Kotlin behavior
|
|
223
|
+
|
|
224
|
+
The v7.2 handoff specifies the **Swift** fallback shape (`AnyCodable` helper) but does not pin the **Kotlin** fallback shape. We need to know it before documenting the flag.
|
|
225
|
+
|
|
226
|
+
`probe-unsupported-unions.test.ts` runs the real `emitKotlin` with `{ oneOf: [{ type: 'string' }, { type: 'integer' }] }` and `unsupportedUnions: 'fallback'`. Asserts via `toMatchSnapshot()` — captures whatever ajsc currently emits. The captured snapshot becomes the basis for the consumer doc's "untagged unions" section. If a future ajsc release changes the fallback shape, the snapshot diff surfaces it.
|
|
227
|
+
|
|
228
|
+
**Gating:** `ajsc` is in `optionalDependencies`, so a contributor running `npm install --omit=optional` won't have it resolvable. The probe gates with the same toolchain-availability pattern as the E2E: `it.skipIf(!ajscResolvable())`. The check tries `import('ajsc')` once at module load and skips if the import throws. No kotlinc needed.
|
|
229
|
+
|
|
230
|
+
## Downstream consumer documentation
|
|
231
|
+
|
|
232
|
+
The highest-DX-impact deliverable in this plan. Without it, the first downstream Android dev hits "why won't `Json.decodeFromString` round-trip my `Instant`?" and the codegen feels broken.
|
|
233
|
+
|
|
234
|
+
### New doc: `docs/codegen-kotlin.md`
|
|
235
|
+
|
|
236
|
+
Self-contained setup guide. Outline:
|
|
237
|
+
|
|
238
|
+
| Section | Content |
|
|
239
|
+
|---|---|
|
|
240
|
+
| Quickstart | `npx ts-procedures-codegen --target kotlin --kotlin-package com.example.api ...`; one `Users.kt` per scope; types accessed as `Users.GetUser.Response`, `Users.GetUser.Body.Address` (nested). |
|
|
241
|
+
| Gradle setup | `kotlin("plugin.serialization")` + `kotlinx-serialization-json` dep with current pin; KMP non-support note (JVM only for now). |
|
|
242
|
+
| Contextual serializers | What `@Contextual` means; copy-pasteable `Json { serializersModule = ... }` block; pointers to public Gist-quality serializers for `Instant`, `UUID`, `URI`, `LocalDate`, `LocalTime`. We recommend ISO-8601 to match server. |
|
|
243
|
+
| Discriminated unions | `sealed interface` shape; `@JsonClassDiscriminator` is read automatically — no extra config. Wire format: discriminator field erased from variants under kotlinx mode. |
|
|
244
|
+
| JSON-key sanitization | `first-name` → `firstName` with `@SerialName("first-name")` auto-emitted. Reserved words → trailing underscore. Nothing to configure. |
|
|
245
|
+
| `--kotlin-serializer none` | Emit plain data classes with no annotations. Adapter setup is your own (Moshi, Gson, hand-written). Discriminator field is **retained** in variants under `none` mode. |
|
|
246
|
+
| `--unsupported-unions fallback` | Kotlin fallback shape sourced from the Section "Layer 4" probe snapshot — **not authored speculatively**. Filled in once probe runs. |
|
|
247
|
+
| Documented limitations | `additionalProperties: { type: T }` dropped with KDoc note; tuples >3 throw; `examples` dropped. What to do if you hit them. |
|
|
248
|
+
|
|
249
|
+
**Distribution:** the doc lives in `docs/` of this repo. The CLI prints a one-line pointer after a successful Kotlin run:
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
[ts-procedures-codegen] Wrote 3 .kt files to ./generated. Setup guide: https://github.com/<org>/ts-procedures/blob/master/docs/codegen-kotlin.md
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Once per invocation. No per-file boilerplate.
|
|
256
|
+
|
|
257
|
+
### `CLAUDE.md` updates
|
|
258
|
+
|
|
259
|
+
Small update to the existing "Kotlin target" bullet:
|
|
260
|
+
|
|
261
|
+
- Output-shape change: `Users.GetUser.Body.Address` (nested) instead of `Users.GetUser.BodyAddress`.
|
|
262
|
+
- New flags: `--kotlin-serializer <kotlinx|none>` (default `kotlinx`), `--unsupported-unions <throw|fallback>` (default `throw`).
|
|
263
|
+
- Pointer to `docs/codegen-kotlin.md` for downstream consumer setup.
|
|
264
|
+
|
|
265
|
+
## Migration / backwards-compatibility
|
|
266
|
+
|
|
267
|
+
This polish lands on the unmerged `codegen-kotlin-swift` branch. Kotlin codegen has not shipped to a tagged release yet, so "migration" means "what changes between this branch's current state and the polished state."
|
|
268
|
+
|
|
269
|
+
| Change | Visible to | Action |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| Output shape: flat → nested (`Body.Address`) | anyone testing the branch | Re-run codegen; references like `BodyAddress` become `Body.Address` |
|
|
272
|
+
| Two new CLI flags | CLI users | None — defaults preserve prior behavior |
|
|
273
|
+
| Realistic fixture replaces trivial one | reviewers of integration golden | Standard golden regeneration |
|
|
274
|
+
| Unit test reshape | nobody outside this repo | None |
|
|
275
|
+
| `optionalDependencies: ajsc` constraint bumps to `^7.2.0` | `package.json` consumers | npm install resolves it |
|
|
276
|
+
|
|
277
|
+
When the branch ships, the `feat!` commit explicitly notes the nested-types output shape so downstream consumers reading the changelog know what to expect.
|
|
278
|
+
|
|
279
|
+
## Risk assessment
|
|
280
|
+
|
|
281
|
+
- **Risk: ajsc v7.2 emits subtly-different output for one of our fixture cases.** Mitigated by the kotlinc E2E test (compiles real ajsc output) plus the probe snapshot for `unsupportedUnions: 'fallback'`.
|
|
282
|
+
- **Risk: realistic fixture is "realistic enough" but misses a pattern that breaks in production.** Mitigated by the layered test pyramid: unit tests pin our wrapping; integration golden pins file assembly; E2E compile pins ajsc semantics; probe pins ajsc-undocumented behavior. We close the loop in production by the first real consumer's feedback, not by trying to enumerate every schema shape upfront.
|
|
283
|
+
- **Risk: nested-types DX is unintuitive for a shop expecting flat names.** Mitigated by the `--kotlin-flat-types` opt-out path remaining available in ajsc; we can add a CLI flag additively without breaking existing output. The handoff doc explicitly says both modes are supported indefinitely.
|
|
284
|
+
- **Risk: Gradle / contextual-serializer setup is too much friction.** Mitigated by `docs/codegen-kotlin.md` having copy-pasteable blocks and serializer pointers, and by `--kotlin-serializer none` as a one-flag escape if a consumer's ecosystem can't accept the kotlinx Gradle plugin.
|
|
285
|
+
- **Risk: CI does not have `kotlinc` available.** The E2E compile test gates on `TS_PROCEDURES_KOTLIN_E2E=1` and toolchain detection; CI without `kotlinc` skips it. Local runs and at-minimum manual pre-release runs catch what CI doesn't.
|
|
286
|
+
|
|
287
|
+
## Open questions / explicit deferrals
|
|
288
|
+
|
|
289
|
+
1. **CI integration of the kotlinc E2E.** Provisioning `kotlinc` + `kotlinx-serialization-json` in CI is a separate, optional decision. This spec ships the test gated; CI activation can follow once a maintainer wants it.
|
|
290
|
+
2. **`UPDATE_GOLDENS=1` ergonomics.** Standard golden-test pattern. If we need richer affordances (per-test golden regeneration, partial-update flags), that's a separate quality-of-life PR.
|
|
291
|
+
3. **Cross-route shared types** — still deferred per original spec.
|
|
292
|
+
4. **Swift target** — still deferred. The architecture designed here generalizes naturally; a separate spec covers Swift idioms.
|
|
293
|
+
5. **`--kotlin-flat-types` opt-out flag** — not exposed in this round. Additive when needed.
|
|
294
|
+
|
|
295
|
+
## Sequencing (execution-order summary, plan deferred)
|
|
296
|
+
|
|
297
|
+
This spec drives a single implementation plan. Execution-order intent:
|
|
298
|
+
|
|
299
|
+
1. `KotlinEmitOptions` shape cleanup (drop `inlineTypes` knob from caller surface, keep hardcoded internally; drop `enumStyle`).
|
|
300
|
+
2. `emit-route-kotlin.ts` → pure function (no registry/prefix); pass `inlineTypes: true`, `serializer`, `unsupportedUnions` per emit; stream routes return `skipped: true`.
|
|
301
|
+
3. Pipeline-level summary log for skipped streams.
|
|
302
|
+
4. CLI: add `--kotlin-serializer`, `--unsupported-unions` flags; thread through.
|
|
303
|
+
5. Realistic fixture replacement; regenerate integration golden.
|
|
304
|
+
6. Probe test for `unsupportedUnions: 'fallback'` Kotlin shape; capture snapshot.
|
|
305
|
+
7. Activate kotlinc E2E (drop ajsc Phase A gate, keep toolchain gate).
|
|
306
|
+
8. `docs/codegen-kotlin.md` authored from v7 handoff + probe snapshot.
|
|
307
|
+
9. CLI prints setup-guide pointer after successful Kotlin run.
|
|
308
|
+
10. `CLAUDE.md` updates.
|
|
309
|
+
|
|
310
|
+
The corresponding implementation plan is the next deliverable — produced from this spec via the `superpowers:writing-plans` skill, not part of this document.
|
|
311
|
+
|
|
312
|
+
## Summary
|
|
313
|
+
|
|
314
|
+
ajsc v7.2 made our originally-planned Kotlin codegen work simpler: `inlineTypes: true` collapses the cross-slot dedup problem by leaning on Kotlin's nested-class scoping. This polish round trims the adapter contract to v7.2's actual surface, adds two CLI flags for ajsc's escape hatches (`--kotlin-serializer`, `--unsupported-unions`), replaces the trivial test fixture with one that exercises real schema patterns, activates the previously-gated kotlinc compile E2E, and ships a downstream-consumer setup guide. Net result: the route emitter becomes a pure function, the integration tests prove real correctness, and the first Android team to integrate has a one-page guide that gets them running without backchannel questions.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Swift Codegen Target — Design & Implementation Plan
|
|
2
|
+
|
|
3
|
+
Date: 2026-04-25
|
|
4
|
+
Branch: codegen-kotlin-swift
|
|
5
|
+
Status: shipped
|
|
6
|
+
|
|
7
|
+
## Outcome
|
|
8
|
+
|
|
9
|
+
Implemented as designed: `--target swift`, `--swift-serializer`, `--swift-access-level`, full integration test against `users-golden.swift`, plus a `swiftc -parse` e2e gated by `swiftc` availability. One post-implementation deviation worth noting: the originally-spec'd files in `src/codegen/targets/swift/` were later supplemented by an extracted `src/codegen/targets/_shared/` directory of language-agnostic utilities (`path-utils`, `pick-defined`, `indent`, `pascal-case`, `route-slots`, `error-schemas`, `write-files`, `target-run`). The Swift run module (`src/codegen/targets/swift/run.ts`) and the Kotlin run module both consume `_shared/` so the dispatcher in `src/codegen/pipeline.ts` stays thin (~94 lines).
|
|
10
|
+
|
|
11
|
+
## Goal
|
|
12
|
+
|
|
13
|
+
Add a `--target swift` codegen target to `ts-procedures-codegen` that emits idiomatic, types-only Swift source from a `DocEnvelope`. Design mirrors the existing Kotlin target so iOS / Apple-platform consumers get parity DX with Android consumers — both targets are types-only, no runtime, no error registry, no HTTP adapter.
|
|
14
|
+
|
|
15
|
+
## Non-goals
|
|
16
|
+
|
|
17
|
+
- HTTP client, networking, async/await wrappers (consumers own this).
|
|
18
|
+
- SSE / streams (skipped, same as Kotlin).
|
|
19
|
+
- Hooks, per-call options, error dispatch logic.
|
|
20
|
+
- Swift Package Manager scaffolding (consumers create `Package.swift` themselves).
|
|
21
|
+
- Module declarations (Swift modules are defined by Xcode/SPM targets, not per-file like Kotlin packages).
|
|
22
|
+
- ObjC interop, `@objc` annotations.
|
|
23
|
+
|
|
24
|
+
## Output shape
|
|
25
|
+
|
|
26
|
+
One `.swift` file per scope. Idiomatic Swift namespacing via **caseless enums** (the standard Swift idiom for namespaces — uninstantiable, zero runtime cost):
|
|
27
|
+
|
|
28
|
+
```swift
|
|
29
|
+
// Source hash: <md5>
|
|
30
|
+
// Generated by ts-procedures-codegen — do not edit.
|
|
31
|
+
import Foundation
|
|
32
|
+
|
|
33
|
+
public enum Users {
|
|
34
|
+
public enum GetUser {
|
|
35
|
+
public static let method = "GET"
|
|
36
|
+
public static let pathTemplate = "/users/{id}"
|
|
37
|
+
public static func path(_ p: PathParams) -> String { return "/users/\(p.id)" }
|
|
38
|
+
|
|
39
|
+
public struct PathParams: Codable {
|
|
40
|
+
public let id: String
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public struct Response: Codable {
|
|
44
|
+
public let id: String
|
|
45
|
+
public let name: String
|
|
46
|
+
/// ISO-8601 — set JSONDecoder.dateDecodingStrategy = .iso8601
|
|
47
|
+
public let createdAt: Date
|
|
48
|
+
public let address: Address
|
|
49
|
+
|
|
50
|
+
enum CodingKeys: String, CodingKey {
|
|
51
|
+
case id, name
|
|
52
|
+
case createdAt = "created-at"
|
|
53
|
+
case address
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public struct Address: Codable {
|
|
57
|
+
public let street: String
|
|
58
|
+
public let city: String
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public enum Errors {
|
|
63
|
+
public struct NotFound: Codable {
|
|
64
|
+
public let name: String
|
|
65
|
+
public let message: String
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Routes without path params get `public static let path = "/users"` (constant, not function).
|
|
73
|
+
|
|
74
|
+
## CLI surface
|
|
75
|
+
|
|
76
|
+
New flag: `--target swift` (extends current `'ts' | 'kotlin'` union to `'ts' | 'kotlin' | 'swift'`).
|
|
77
|
+
|
|
78
|
+
Swift-specific flags:
|
|
79
|
+
|
|
80
|
+
| Flag | Default | Notes |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| `--swift-serializer <codable\|none>` | `codable` | Emits `: Codable`/`CodingKeys`. `none` for plain structs (consumer handles serialization). |
|
|
83
|
+
| `--swift-access-level <public\|internal>` | `public` | Threads through to ajsc `accessLevel`. |
|
|
84
|
+
|
|
85
|
+
Reused (apply to all targets): `--unsupported-unions`, `--array-item-naming`, `--depluralize`, `--uncountable-words`.
|
|
86
|
+
|
|
87
|
+
**Important:** Unlike Kotlin, `--unsupported-unions fallback` actually works on Swift — ajsc emits a self-contained `AnyCodable` helper struct. Do NOT add the kotlin-style "no-op warning" for Swift.
|
|
88
|
+
|
|
89
|
+
**No `--swift-package` flag.** Swift has no file-level package/module declaration; modules are defined by SPM/Xcode targets. This is a deliberate DX win — one fewer required arg vs Kotlin.
|
|
90
|
+
|
|
91
|
+
## Config file shape
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"target": "swift",
|
|
96
|
+
"swift": {
|
|
97
|
+
"serializer": "codable",
|
|
98
|
+
"accessLevel": "public"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## File layout
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
src/codegen/targets/swift/
|
|
107
|
+
├── ajsc-adapter.ts # SwiftEmitter interface + production wrapper around ajsc.emitSwift
|
|
108
|
+
├── ajsc-adapter.test.ts
|
|
109
|
+
├── format-swift.ts # header, imports, indent, pickDefined helpers
|
|
110
|
+
├── format-swift.test.ts
|
|
111
|
+
├── emit-route-swift.ts # per-route emitter (PathParams, Query, Body, Response, Errors)
|
|
112
|
+
├── emit-route-swift.test.ts
|
|
113
|
+
├── emit-scope-swift.ts # per-scope emitter (wraps routes in nested enum namespace)
|
|
114
|
+
├── emit-scope-swift.test.ts
|
|
115
|
+
├── integration.test.ts # golden-file end-to-end with stub emitter
|
|
116
|
+
├── e2e-compile.test.ts # SKIPPED by default — runs swiftc to validate generated output
|
|
117
|
+
└── __fixtures__/
|
|
118
|
+
├── users-envelope.json # copy of kotlin fixture (same input)
|
|
119
|
+
└── users-golden.swift
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Architectural decisions
|
|
123
|
+
|
|
124
|
+
### 1. Namespacing via caseless enums
|
|
125
|
+
|
|
126
|
+
Swift idiom: `public enum Users {}` for namespace. Better than `struct Users {}` (no init, no instantiation possible, zero runtime cost). Better than nested `class` (reference type, unnecessary).
|
|
127
|
+
|
|
128
|
+
### 2. Static members for path/method
|
|
129
|
+
|
|
130
|
+
Inside an enum namespace, all members must be `static`. So:
|
|
131
|
+
```swift
|
|
132
|
+
public static let method = "GET"
|
|
133
|
+
public static let pathTemplate = "/users/{id}"
|
|
134
|
+
public static func path(_ p: PathParams) -> String { return "/users/\(p.id)" }
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
(Kotlin uses `const val` and top-level `fun`; Swift uses `public static let` and `public static func`.)
|
|
138
|
+
|
|
139
|
+
### 3. ajsc options threaded
|
|
140
|
+
|
|
141
|
+
Per route slot we call `emitSwift(slotSchema, { rootTypeName, inlineTypes: true, ...passthrough })`. Passthrough: `serializer`, `accessLevel`, `unsupportedUnions`, `arrayItemNaming`, `depluralize`, `uncountableWords`.
|
|
142
|
+
|
|
143
|
+
`inlineTypes: true` is critical — same as Kotlin. Without it, nested object types extract to siblings and clutter the namespace.
|
|
144
|
+
|
|
145
|
+
### 4. Imports merge per file
|
|
146
|
+
|
|
147
|
+
ajsc returns imports per emit call (typically `["Foundation"]` only when Date/UUID/URL is in the schema). We dedupe + sort and emit once at the top of the scope file.
|
|
148
|
+
|
|
149
|
+
### 5. No errors file, no index file
|
|
150
|
+
|
|
151
|
+
Mirrors Kotlin: errors are nested as `enum Errors { struct NotFound: Codable { ... } }` per route. No `_errors.swift`, no barrel/index, no factories.
|
|
152
|
+
|
|
153
|
+
### 6. Path interpolation
|
|
154
|
+
|
|
155
|
+
Swift string interpolation is `\(expr)` (backslash + paren). Path builder template:
|
|
156
|
+
```swift
|
|
157
|
+
public static func path(_ p: PathParams) -> String { return "/users/\(p.id)" }
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
For no-params: `public static let path = "/users"`.
|
|
161
|
+
|
|
162
|
+
### 7. Source hash header
|
|
163
|
+
|
|
164
|
+
`// Source hash: <md5>` as second line (after the optional `// Generated by ...` line). Same as kotlin/ts.
|
|
165
|
+
|
|
166
|
+
### 8. Stream skipping
|
|
167
|
+
|
|
168
|
+
Same as Kotlin — skip stream routes with a single console log per scope.
|
|
169
|
+
|
|
170
|
+
### 9. Reserved word + identifier sanitization
|
|
171
|
+
|
|
172
|
+
ajsc handles this via `sanitizeSwiftIdentifier` (already verified in ajsc/swift exports). Property and case names are already sanitized in ajsc's output. Our route/scope names use PascalCase already; we don't need to re-sanitize.
|
|
173
|
+
|
|
174
|
+
### 10. Self-contained emitter (no extra runtime files)
|
|
175
|
+
|
|
176
|
+
Like Kotlin: zero runtime files emitted. ajsc handles `AnyCodable` inline when `unsupportedUnions: 'fallback'` is set — we don't emit a separate helper file.
|
|
177
|
+
|
|
178
|
+
## Pipeline integration
|
|
179
|
+
|
|
180
|
+
`src/codegen/pipeline.ts` gets a third branch parallel to kotlin:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
if (options.target === 'kotlin') { ... }
|
|
184
|
+
if (options.target === 'swift') {
|
|
185
|
+
if (options.swiftEmitter == null) throw new Error(...)
|
|
186
|
+
// ... build errorSchemas, iterate groups, call emitSwiftScope, write files
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Naming inside `PipelineOptions`:
|
|
191
|
+
- `target?: 'ts' | 'kotlin' | 'swift'`
|
|
192
|
+
- `swiftSerializer?: 'codable' | 'none'`
|
|
193
|
+
- `swiftAccessLevel?: 'public' | 'internal'`
|
|
194
|
+
- `swiftEmitter?: SwiftEmitter` (test-injection hook, mirrors `kotlinEmitter`)
|
|
195
|
+
|
|
196
|
+
Reused: `unsupportedUnions` (already on options).
|
|
197
|
+
|
|
198
|
+
## CLI integration
|
|
199
|
+
|
|
200
|
+
`src/codegen/bin/cli.ts`:
|
|
201
|
+
|
|
202
|
+
1. Extend `target` parser to accept `'swift'`.
|
|
203
|
+
2. Add flags: `--swift-serializer`, `--swift-access-level`.
|
|
204
|
+
3. Add `swift?: { serializer?, accessLevel? }` to `CodegenConfig` and `ParsedArgs`.
|
|
205
|
+
4. No package validation (Swift has no required field).
|
|
206
|
+
5. Extend `printPostRunHints` for swift target → link `docs/codegen-swift.md`.
|
|
207
|
+
6. Watch-mode: resolve swift emitter once at startup, parallel to kotlin.
|
|
208
|
+
7. Do NOT add a Swift no-op-flag warning for `--unsupported-unions` (it works on Swift).
|
|
209
|
+
|
|
210
|
+
## Tests
|
|
211
|
+
|
|
212
|
+
Mirror the Kotlin test layout:
|
|
213
|
+
|
|
214
|
+
1. **`ajsc-adapter.test.ts`** — stub creator + production resolver (mocking `ajsc` module to assert error messages when missing or `emitSwift` is undefined).
|
|
215
|
+
2. **`format-swift.test.ts`** — `swiftHeader`, `swiftImports` dedupe+sort, `indent`, `pickDefined`.
|
|
216
|
+
3. **`emit-route-swift.test.ts`** — path-builder for params/no-params, slot order (PathParams, Query, Body, Response), Errors namespace, stream-skipping.
|
|
217
|
+
4. **`emit-scope-swift.test.ts`** — PascalCase scope name, file-name `Foo.swift`, imports dedupe across routes, empty scope, option threading.
|
|
218
|
+
5. **`integration.test.ts`** — golden-file test using the `users-envelope.json` fixture and a hand-stubbed emitter, byte-identical against `users-golden.swift`.
|
|
219
|
+
6. **`e2e-compile.test.ts`** — `it.skipIf(no swiftc)` — invokes `swiftc -parse` on the generated file to verify syntactic correctness. Skipped by default; only runs when `swiftc` is on `PATH`.
|
|
220
|
+
|
|
221
|
+
Tests inject `createStubSwiftEmitter()` so output is deterministic and independent of ajsc's per-version evolution. The E2E compile test is the only one that exercises real ajsc.
|
|
222
|
+
|
|
223
|
+
## Documentation
|
|
224
|
+
|
|
225
|
+
1. **`docs/codegen-swift.md`** — full setup guide:
|
|
226
|
+
- Quickstart example
|
|
227
|
+
- SPM/Xcode integration (target setup, file inclusion)
|
|
228
|
+
- JSONDecoder configuration (`.iso8601` for Date)
|
|
229
|
+
- Sample output
|
|
230
|
+
- Discriminated unions (Swift uses `init(from:)` / `encode(to:)`)
|
|
231
|
+
- JSON-key sanitization (CodingKeys)
|
|
232
|
+
- `--swift-serializer none` (when consumers want plain structs for SwiftJSON or hand-rolled coding)
|
|
233
|
+
- Error types (nested under `Errors`)
|
|
234
|
+
- Untagged unions (`fallback` works on Swift, emits `AnyCodable`)
|
|
235
|
+
- Documented limitations
|
|
236
|
+
|
|
237
|
+
2. **`CLAUDE.md`** — add Swift target paragraph parallel to Kotlin; emphasize differences (`--unsupported-unions fallback` works for Swift; no `--swift-package` requirement).
|
|
238
|
+
|
|
239
|
+
3. **`agent_config/claude-code/skills/ts-procedures-swift/SKILL.md`** — parallel to `ts-procedures-kotlin/SKILL.md`. Cross-link both kotlin and swift skills from the main `ts-procedures` skill.
|
|
240
|
+
|
|
241
|
+
## Implementation phases
|
|
242
|
+
|
|
243
|
+
**Phase 1 (foundation):** ajsc-adapter, format helpers, emit-route, emit-scope (+ unit tests for each). Single sub-agent.
|
|
244
|
+
|
|
245
|
+
**Phase 2 (integration):** Wire into pipeline.ts, index.ts, cli.ts. Add integration test with golden file. Single sub-agent depending on Phase 1.
|
|
246
|
+
|
|
247
|
+
**Phase 3 (docs+skill, parallel with Phase 1/2):** Write `docs/codegen-swift.md`, update CLAUDE.md, create agent_config skill. Single sub-agent independent of code.
|
|
248
|
+
|
|
249
|
+
**Phase 4 (verify):** `npm run build && npm test && npm run lint`. Inspect generated golden file. Confirm CLI invocation works.
|
|
250
|
+
|
|
251
|
+
## Risk / open questions
|
|
252
|
+
|
|
253
|
+
- **swiftc on dev machines** — E2E test is skipped by default to avoid CI failures. Same pattern as kotlin's `e2e-compile.test.ts`.
|
|
254
|
+
- **`type: integer` → `Int64`** — Swift idiom is `Int` for general use; `Int64` is heavier visually. Accept ajsc's choice for now (can revisit if mobile devs request `Int`).
|
|
255
|
+
- **`type: number` → `Double`** — for monetary values, mobile devs should use `Decimal`. Document the workaround in `docs/codegen-swift.md`.
|
|
256
|
+
- **CodingKeys are mandatory** when keys differ — ajsc emits these automatically. We just have to make sure our golden file includes them and tests don't lock them away.
|
|
257
|
+
|
|
258
|
+
## Success criteria
|
|
259
|
+
|
|
260
|
+
- `npm test` passes (all kotlin tests still pass, new swift tests pass).
|
|
261
|
+
- `npm run build` succeeds.
|
|
262
|
+
- `npm run lint` clean.
|
|
263
|
+
- Manual run: `npx ts-procedures-codegen --target swift --url ... --out ...` produces a valid `Users.swift` (or whatever scope) that compiles with `swiftc -parse`.
|
|
264
|
+
- Docs and skill files committed; `agent_config/postinstall` will distribute them on next install.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ts-procedures",
|
|
3
|
-
"version": "6.0
|
|
3
|
+
"version": "6.2.0",
|
|
4
4
|
"description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
|
|
5
5
|
"main": "build/exports.js",
|
|
6
6
|
"types": "build/exports.d.ts",
|
|
@@ -78,7 +78,7 @@
|
|
|
78
78
|
"framework"
|
|
79
79
|
],
|
|
80
80
|
"optionalDependencies": {
|
|
81
|
-
"ajsc": "
|
|
81
|
+
"ajsc": "7.2.0",
|
|
82
82
|
"express": "^5.2.1",
|
|
83
83
|
"hono": "^4.7.4"
|
|
84
84
|
},
|