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.
Files changed (77) hide show
  1. package/agent_config/bin/setup.mjs +0 -0
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -0
  3. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +2 -0
  4. package/agent_config/claude-code/skills/ts-procedures-kotlin/SKILL.md +106 -0
  5. package/agent_config/copilot/copilot-instructions.md +2 -0
  6. package/agent_config/cursor/cursorrules +2 -0
  7. package/build/codegen/bin/cli.d.ts +25 -0
  8. package/build/codegen/bin/cli.js +88 -0
  9. package/build/codegen/bin/cli.js.map +1 -1
  10. package/build/codegen/bin/cli.test.js +180 -1
  11. package/build/codegen/bin/cli.test.js.map +1 -1
  12. package/build/codegen/index.d.ts +19 -0
  13. package/build/codegen/index.js +5 -0
  14. package/build/codegen/index.js.map +1 -1
  15. package/build/codegen/pipeline.d.ts +7 -0
  16. package/build/codegen/pipeline.js +57 -0
  17. package/build/codegen/pipeline.js.map +1 -1
  18. package/build/codegen/pipeline.test.js +162 -0
  19. package/build/codegen/pipeline.test.js.map +1 -1
  20. package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +6 -4
  21. package/build/codegen/targets/kotlin/ajsc-adapter.js +12 -7
  22. package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -1
  23. package/build/codegen/targets/kotlin/ajsc-adapter.test.js +20 -2
  24. package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -1
  25. package/build/codegen/targets/kotlin/e2e-compile.test.js +41 -9
  26. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -1
  27. package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +6 -2
  28. package/build/codegen/targets/kotlin/emit-route-kotlin.js +18 -11
  29. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -1
  30. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +120 -1
  31. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
  32. package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +4 -1
  33. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +9 -4
  34. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -1
  35. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +39 -0
  36. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
  37. package/build/codegen/targets/kotlin/format-kotlin.d.ts +11 -0
  38. package/build/codegen/targets/kotlin/format-kotlin.js +20 -0
  39. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -1
  40. package/build/codegen/targets/kotlin/format-kotlin.test.js +27 -1
  41. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -1
  42. package/build/codegen/targets/kotlin/integration.test.js +26 -9
  43. package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
  44. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.d.ts +1 -0
  45. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js +50 -0
  46. package/build/codegen/targets/kotlin/probe-unsupported-unions.test.js.map +1 -0
  47. package/build/codegen/test-helpers/golden.d.ts +15 -0
  48. package/build/codegen/test-helpers/golden.js +30 -0
  49. package/build/codegen/test-helpers/golden.js.map +1 -0
  50. package/build/codegen/test-helpers/golden.test.d.ts +1 -0
  51. package/build/codegen/test-helpers/golden.test.js +76 -0
  52. package/build/codegen/test-helpers/golden.test.js.map +1 -0
  53. package/docs/codegen-kotlin.md +175 -0
  54. package/docs/superpowers/plans/2026-04-25-ajsc-v7-kotlin-polish.md +1993 -0
  55. package/docs/superpowers/specs/2026-04-25-ajsc-v7-kotlin-polish-design.md +314 -0
  56. package/package.json +2 -2
  57. package/src/codegen/bin/cli.test.ts +200 -1
  58. package/src/codegen/bin/cli.ts +103 -0
  59. package/src/codegen/index.ts +27 -0
  60. package/src/codegen/pipeline.test.ts +175 -0
  61. package/src/codegen/pipeline.ts +79 -0
  62. package/src/codegen/targets/kotlin/__fixtures__/users-envelope.json +144 -0
  63. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +121 -0
  64. package/src/codegen/targets/kotlin/__snapshots__/probe-unsupported-unions.test.ts.snap +27 -0
  65. package/src/codegen/targets/kotlin/ajsc-adapter.test.ts +47 -0
  66. package/src/codegen/targets/kotlin/ajsc-adapter.ts +66 -0
  67. package/src/codegen/targets/kotlin/e2e-compile.test.ts +86 -0
  68. package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +239 -0
  69. package/src/codegen/targets/kotlin/emit-route-kotlin.ts +109 -0
  70. package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +112 -0
  71. package/src/codegen/targets/kotlin/emit-scope-kotlin.ts +65 -0
  72. package/src/codegen/targets/kotlin/format-kotlin.test.ts +70 -0
  73. package/src/codegen/targets/kotlin/format-kotlin.ts +45 -0
  74. package/src/codegen/targets/kotlin/integration.test.ts +77 -0
  75. package/src/codegen/targets/kotlin/probe-unsupported-unions.test.ts +64 -0
  76. package/src/codegen/test-helpers/golden.test.ts +80 -0
  77. package/src/codegen/test-helpers/golden.ts +34 -0
@@ -0,0 +1,314 @@
1
+ # ajsc v7.2 Kotlin Codegen Polish
2
+
3
+ **Status:** Design
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "6.0.2",
3
+ "version": "6.1.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": "^5.2.4",
81
+ "ajsc": "7.2",
82
82
  "express": "^5.2.1",
83
83
  "hono": "^4.7.4"
84
84
  },
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseArgs, loadConfigFile, extractConfigPath, type CodegenConfig } from './cli.js'
2
+ import { vi } from 'vitest'
3
+ import { parseArgs, loadConfigFile, extractConfigPath, printPostRunHints, warnIfKotlinNoOpFlags, type CodegenConfig } from './cli.js'
3
4
 
4
5
  describe('parseArgs', () => {
5
6
  it('parses --url and --out', () => {
@@ -293,3 +294,201 @@ describe('config file support', () => {
293
294
  await expect(loadConfigFile('/nonexistent/config.json')).rejects.toThrow('Failed to load config')
294
295
  })
295
296
  })
297
+
298
+ describe('cli — kotlin target', () => {
299
+ it('parses --target kotlin and --kotlin-package from CLI flags', () => {
300
+ const args = parseArgs(
301
+ ['--target', 'kotlin', '--kotlin-package', 'com.example.api', '--out', 'out', '--file', 'env.json'],
302
+ )
303
+ expect(args.target).toBe('kotlin')
304
+ expect(args.kotlin?.package).toBe('com.example.api')
305
+ })
306
+
307
+ it('reads kotlin.package from config when no CLI flag is provided', () => {
308
+ const config: CodegenConfig = {
309
+ target: 'kotlin',
310
+ kotlin: { package: 'com.example.api' },
311
+ outDir: 'out',
312
+ file: 'env.json',
313
+ }
314
+ const args = parseArgs(['--out', 'out', '--file', 'env.json'], config)
315
+ expect(args.target).toBe('kotlin')
316
+ expect(args.kotlin?.package).toBe('com.example.api')
317
+ })
318
+
319
+ it('CLI flag overrides config value', () => {
320
+ const config: CodegenConfig = {
321
+ target: 'kotlin',
322
+ kotlin: { package: 'old.pkg' },
323
+ outDir: 'out',
324
+ file: 'env.json',
325
+ }
326
+ const args = parseArgs(['--kotlin-package', 'new.pkg', '--out', 'out', '--file', 'env.json'], config)
327
+ expect(args.kotlin?.package).toBe('new.pkg')
328
+ })
329
+
330
+ it('errors when target is kotlin and no package is provided', () => {
331
+ expect(() =>
332
+ parseArgs(['--target', 'kotlin', '--out', 'out', '--file', 'env.json']),
333
+ ).toThrow(/--kotlin-package/)
334
+ })
335
+
336
+ it('--target ts is the default', () => {
337
+ const args = parseArgs(['--out', 'out', '--file', 'env.json'])
338
+ expect(args.target ?? 'ts').toBe('ts')
339
+ })
340
+ })
341
+
342
+ describe('cli — kotlin-serializer flag', () => {
343
+ it('parses --kotlin-serializer kotlinx', () => {
344
+ const args = parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'kotlinx', '--out', 'o', '--file', 'e.json'])
345
+ expect(args.kotlin?.serializer).toBe('kotlinx')
346
+ })
347
+
348
+ it('parses --kotlin-serializer none', () => {
349
+ const args = parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'none', '--out', 'o', '--file', 'e.json'])
350
+ expect(args.kotlin?.serializer).toBe('none')
351
+ })
352
+
353
+ it('reads kotlin.serializer from config', () => {
354
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'], {
355
+ target: 'kotlin', kotlin: { package: 'p', serializer: 'none' }, outDir: 'o', file: 'e.json',
356
+ } as CodegenConfig)
357
+ expect(args.kotlin?.serializer).toBe('none')
358
+ })
359
+
360
+ it('CLI overrides config', () => {
361
+ const args = parseArgs(['--kotlin-serializer', 'kotlinx', '--out', 'o', '--file', 'e.json'], {
362
+ target: 'kotlin', kotlin: { package: 'p', serializer: 'none' }, outDir: 'o', file: 'e.json',
363
+ } as CodegenConfig)
364
+ expect(args.kotlin?.serializer).toBe('kotlinx')
365
+ })
366
+
367
+ it('throws on invalid value', () => {
368
+ expect(() => parseArgs(['--target', 'kotlin', '--kotlin-package', 'p', '--kotlin-serializer', 'bogus', '--out', 'o', '--file', 'e.json']))
369
+ .toThrow(/--kotlin-serializer/)
370
+ })
371
+ })
372
+
373
+ describe('cli — unsupported-unions flag', () => {
374
+ it('parses --unsupported-unions throw', () => {
375
+ const args = parseArgs(['--unsupported-unions', 'throw', '--out', 'o', '--file', 'e.json'])
376
+ expect(args.unsupportedUnions).toBe('throw')
377
+ })
378
+
379
+ it('parses --unsupported-unions fallback', () => {
380
+ const args = parseArgs(['--unsupported-unions', 'fallback', '--out', 'o', '--file', 'e.json'])
381
+ expect(args.unsupportedUnions).toBe('fallback')
382
+ })
383
+
384
+ it('reads unsupportedUnions from config', () => {
385
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'], {
386
+ unsupportedUnions: 'fallback', outDir: 'o', file: 'e.json',
387
+ } as CodegenConfig)
388
+ expect(args.unsupportedUnions).toBe('fallback')
389
+ })
390
+
391
+ it('CLI overrides config', () => {
392
+ const args = parseArgs(['--unsupported-unions', 'throw', '--out', 'o', '--file', 'e.json'], {
393
+ unsupportedUnions: 'fallback', outDir: 'o', file: 'e.json',
394
+ } as CodegenConfig)
395
+ expect(args.unsupportedUnions).toBe('throw')
396
+ })
397
+
398
+ it('throws on invalid value', () => {
399
+ expect(() => parseArgs(['--unsupported-unions', 'bogus', '--out', 'o', '--file', 'e.json']))
400
+ .toThrow(/--unsupported-unions/)
401
+ })
402
+
403
+ it('default is undefined when not set', () => {
404
+ const args = parseArgs(['--out', 'o', '--file', 'e.json'])
405
+ expect(args.unsupportedUnions).toBeUndefined()
406
+ })
407
+ })
408
+
409
+ describe('cli — printPostRunHints', () => {
410
+ it('prints a setup-guide pointer for the kotlin target', () => {
411
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
412
+ try {
413
+ printPostRunHints({ target: 'kotlin' })
414
+ const matched = logSpy.mock.calls.some((c) => String(c[0]).includes('docs/codegen-kotlin.md'))
415
+ expect(matched).toBe(true)
416
+ } finally {
417
+ logSpy.mockRestore()
418
+ }
419
+ })
420
+
421
+ it('prints nothing for the ts target', () => {
422
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
423
+ try {
424
+ printPostRunHints({ target: 'ts' })
425
+ expect(logSpy).not.toHaveBeenCalled()
426
+ } finally {
427
+ logSpy.mockRestore()
428
+ }
429
+ })
430
+
431
+ it('prints nothing when target is undefined (default ts)', () => {
432
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
433
+ try {
434
+ printPostRunHints({})
435
+ expect(logSpy).not.toHaveBeenCalled()
436
+ } finally {
437
+ logSpy.mockRestore()
438
+ }
439
+ })
440
+ })
441
+
442
+ describe('cli — warnIfKotlinNoOpFlags', () => {
443
+ it('warns when --unsupported-unions is set with --target kotlin', () => {
444
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
445
+ try {
446
+ warnIfKotlinNoOpFlags({ target: 'kotlin', unsupportedUnions: 'fallback' })
447
+ const matched = warnSpy.mock.calls.some((c) => String(c[0]).includes('--unsupported-unions is currently a no-op'))
448
+ expect(matched).toBe(true)
449
+ } finally {
450
+ warnSpy.mockRestore()
451
+ }
452
+ })
453
+
454
+ it('warns when --unsupported-unions is set to throw with --target kotlin', () => {
455
+ // Even 'throw' is a no-op since ajsc never throws on Kotlin untagged oneOf.
456
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
457
+ try {
458
+ warnIfKotlinNoOpFlags({ target: 'kotlin', unsupportedUnions: 'throw' })
459
+ expect(warnSpy).toHaveBeenCalled()
460
+ } finally {
461
+ warnSpy.mockRestore()
462
+ }
463
+ })
464
+
465
+ it('does not warn when --unsupported-unions is unset', () => {
466
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
467
+ try {
468
+ warnIfKotlinNoOpFlags({ target: 'kotlin' })
469
+ expect(warnSpy).not.toHaveBeenCalled()
470
+ } finally {
471
+ warnSpy.mockRestore()
472
+ }
473
+ })
474
+
475
+ it('does not warn for the ts target even when --unsupported-unions is set', () => {
476
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
477
+ try {
478
+ warnIfKotlinNoOpFlags({ target: 'ts', unsupportedUnions: 'fallback' })
479
+ expect(warnSpy).not.toHaveBeenCalled()
480
+ } finally {
481
+ warnSpy.mockRestore()
482
+ }
483
+ })
484
+
485
+ it('does not warn when target is undefined', () => {
486
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
487
+ try {
488
+ warnIfKotlinNoOpFlags({ unsupportedUnions: 'fallback' })
489
+ expect(warnSpy).not.toHaveBeenCalled()
490
+ } finally {
491
+ warnSpy.mockRestore()
492
+ }
493
+ })
494
+ })