ts-procedures 6.0.0 → 6.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/agent_config/claude-code/agents/ts-procedures-architect.md +1 -1
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +1 -1
  3. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +2 -2
  4. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +15 -27
  5. package/agent_config/claude-code/skills/ts-procedures/patterns.md +11 -4
  6. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +2 -2
  7. package/agent_config/copilot/copilot-instructions.md +3 -2
  8. package/agent_config/cursor/cursorrules +3 -2
  9. package/build/codegen/targets/kotlin/ajsc-adapter.d.ts +24 -0
  10. package/build/codegen/targets/kotlin/ajsc-adapter.js +33 -0
  11. package/build/codegen/targets/kotlin/ajsc-adapter.js.map +1 -0
  12. package/build/codegen/targets/kotlin/ajsc-adapter.test.d.ts +1 -0
  13. package/build/codegen/targets/kotlin/ajsc-adapter.test.js +19 -0
  14. package/build/codegen/targets/kotlin/ajsc-adapter.test.js.map +1 -0
  15. package/build/codegen/targets/kotlin/e2e-compile.test.d.ts +1 -0
  16. package/build/codegen/targets/kotlin/e2e-compile.test.js +43 -0
  17. package/build/codegen/targets/kotlin/e2e-compile.test.js.map +1 -0
  18. package/build/codegen/targets/kotlin/emit-route-kotlin.d.ts +11 -0
  19. package/build/codegen/targets/kotlin/emit-route-kotlin.js +73 -0
  20. package/build/codegen/targets/kotlin/emit-route-kotlin.js.map +1 -0
  21. package/build/codegen/targets/kotlin/emit-route-kotlin.test.d.ts +1 -0
  22. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +88 -0
  23. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -0
  24. package/build/codegen/targets/kotlin/emit-scope-kotlin.d.ts +11 -0
  25. package/build/codegen/targets/kotlin/emit-scope-kotlin.js +35 -0
  26. package/build/codegen/targets/kotlin/emit-scope-kotlin.js.map +1 -0
  27. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.d.ts +1 -0
  28. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +52 -0
  29. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -0
  30. package/build/codegen/targets/kotlin/format-kotlin.d.ts +4 -0
  31. package/build/codegen/targets/kotlin/format-kotlin.js +20 -0
  32. package/build/codegen/targets/kotlin/format-kotlin.js.map +1 -0
  33. package/build/codegen/targets/kotlin/format-kotlin.test.d.ts +1 -0
  34. package/build/codegen/targets/kotlin/format-kotlin.test.js +24 -0
  35. package/build/codegen/targets/kotlin/format-kotlin.test.js.map +1 -0
  36. package/build/codegen/targets/kotlin/integration.test.d.ts +1 -0
  37. package/build/codegen/targets/kotlin/integration.test.js +34 -0
  38. package/build/codegen/targets/kotlin/integration.test.js.map +1 -0
  39. package/build/implementations/http/doc-registry.d.ts +14 -19
  40. package/build/implementations/http/doc-registry.js +41 -46
  41. package/build/implementations/http/doc-registry.js.map +1 -1
  42. package/build/implementations/http/doc-registry.test.js +141 -10
  43. package/build/implementations/http/doc-registry.test.js.map +1 -1
  44. package/build/implementations/http/error-taxonomy.d.ts +11 -2
  45. package/build/implementations/http/error-taxonomy.js +24 -2
  46. package/build/implementations/http/error-taxonomy.js.map +1 -1
  47. package/build/implementations/http/route-errors.test.js +5 -6
  48. package/build/implementations/http/route-errors.test.js.map +1 -1
  49. package/build/implementations/types.d.ts +13 -1
  50. package/docs/http-integrations.md +39 -5
  51. package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
  52. package/docs/superpowers/plans/2026-04-24-kotlin-codegen-target.md +1265 -0
  53. package/docs/superpowers/specs/2026-04-24-kotlin-swift-codegen-design.md +401 -0
  54. package/package.json +1 -1
  55. package/src/implementations/http/README.md +4 -3
  56. package/src/implementations/http/doc-registry.test.ts +154 -10
  57. package/src/implementations/http/doc-registry.ts +46 -53
  58. package/src/implementations/http/error-taxonomy.ts +26 -2
  59. package/src/implementations/http/express-rpc/README.md +2 -2
  60. package/src/implementations/http/hono-rpc/README.md +2 -2
  61. package/src/implementations/http/hono-stream/README.md +15 -0
  62. package/src/implementations/http/route-errors.test.ts +5 -6
  63. package/src/implementations/types.ts +13 -1
@@ -0,0 +1,401 @@
1
+ # Kotlin & Swift Codegen for ts-procedures
2
+
3
+ **Status:** Design
4
+ **Date:** 2026-04-24
5
+ **Author:** Cory Robinson
6
+ **Implementation order:** Kotlin first, Swift second.
7
+
8
+ ## Context
9
+
10
+ `ts-procedures` already generates a TypeScript client from a `DocEnvelope` so web developers consume backend procedures with full type safety and zero schema duplication. The same problem exists for native mobile consumers: Android and iOS developers currently hand-write request/response types that mirror the server's schema, then re-edit those types every time the backend changes. This is the precise pain the TS codegen was built to eliminate — extending it to Kotlin and Swift removes the same drift for mobile.
11
+
12
+ ## Goals
13
+
14
+ 1. Generate **Kotlin** and **Swift** type definitions plus URL/method primitives directly from a ts-procedures `DocEnvelope`.
15
+ 2. Be **non-invasive**: do not impose an HTTP stack, networking adapter, or runtime dependency on downstream mobile codebases.
16
+ 3. Preserve dot-notation ergonomics consistent with the TS namespace mode (`Users.GetUser.Response`) so mobile devs can reference generated types directly in their own code.
17
+ 4. Keep the codegen surface and maintenance burden **small** — add Kotlin/Swift without forcing a parallel runtime client to track every TS-side change.
18
+
19
+ ## Non-goals (explicit deferrals)
20
+
21
+ - **No call functions, HTTP adapter, or reference adapter implementation.** Mobile devs own the HTTP layer entirely.
22
+ - **No streams (SSE).** Stream support is deferred; mobile devs needing streaming wire it themselves.
23
+ - **No hooks pipeline** (`onBeforeRequest` / `onAfterResponse` / `onError` / `onRequestError`).
24
+ - **No per-call options** (`signal`, `timeout`, `headers`, `basePath`, `meta`).
25
+ - **No error registry or typed error dispatch logic.** Error *types* are emitted; runtime dispatch is the consumer's job.
26
+ - **No base-URL handling.** Generated paths are relative; consumers prepend their environment base URL.
27
+ - **No Kotlin Multiplatform-specific output.** Initial Kotlin target is JVM/Android; KMP-friendliness comes from kotlinx.serialization but is not a tested target.
28
+
29
+ These are deliberate scope cuts, not architectural impossibilities — they may be revisited in a follow-up once the minimum-viable target is in production use.
30
+
31
+ ## Architecture overview
32
+
33
+ Two packages collaborate:
34
+
35
+ - **`ajsc`** owns "JSON Schema → target-language types." Today it emits TypeScript; this design extends it with Kotlin and Swift emitters that share its existing schema walker, naming-convention logic (`enumStyle`, `depluralize`, `arrayItemNaming`, `uncountableWords`), and sub-type extraction.
36
+ - **`ts-procedures`** owns "DocEnvelope → per-scope output files." It resolves the envelope, groups routes by scope, calls into ajsc once per schema slot (path params / query / body / response / per-error type), and wraps the returned code in route-level `object` (Kotlin) or case-less `enum` (Swift) blocks alongside HTTP method constants and path builders.
37
+
38
+ ```
39
+ DocEnvelope
40
+
41
+ ├── ts-procedures: resolveEnvelope → groupRoutesByScope
42
+
43
+ └── per route, per schema slot:
44
+
45
+ ├── ajsc.emitKotlin(schema, opts) → { code, rootTypeName, extractedTypeNames, imports }
46
+
47
+ └── ts-procedures wraps emitted code into:
48
+ object Users { object GetUser {
49
+ const val method = "GET"
50
+ const val pathTemplate = "/users/{id}"
51
+ fun path(p: PathParams): String = "/users/${p.id}"
52
+ // <ajsc-emitted PathParams, Response, Errors.* types here>
53
+ } }
54
+ ```
55
+
56
+ ts-procedures never does schema → type emission itself. ajsc never knows about routes, scopes, or DocEnvelopes.
57
+
58
+ ## Generated output shape
59
+
60
+ ### Kotlin
61
+
62
+ One file per scope (mirrors the TS codegen). Path templates emit as constants when there are no path params; as a function when there are.
63
+
64
+ ```kotlin
65
+ // users.kt — generated by ts-procedures-codegen, do not edit
66
+ package com.example.api // configurable
67
+
68
+ import kotlinx.serialization.Serializable
69
+ import kotlinx.serialization.SerialName
70
+
71
+ object Users {
72
+ object GetUser {
73
+ const val method = "GET"
74
+ const val pathTemplate = "/users/{id}"
75
+ fun path(p: PathParams): String = "/users/${p.id}"
76
+
77
+ @Serializable data class PathParams(val id: String)
78
+
79
+ @Serializable data class Response(
80
+ val id: String,
81
+ val name: String,
82
+ val address: Address,
83
+ )
84
+
85
+ // sub-type extracted from Response.address
86
+ @Serializable data class Address(val street: String, val city: String)
87
+
88
+ object Errors {
89
+ // `name` is a regular String field with a constructor default matching the
90
+ // server's wire-protocol value. kotlinx.serialization does not enforce
91
+ // literal-type discriminators here — consumers identify error variants by
92
+ // checking `body.name` themselves (this is a deliberate non-goal of the
93
+ // generated code, see "Non-goals" → no error registry).
94
+ @Serializable data class NotFound(
95
+ val name: String = "NotFound",
96
+ val message: String,
97
+ )
98
+ }
99
+ }
100
+
101
+ object CreateUser {
102
+ const val method = "POST"
103
+ const val path = "/users" // no path params → const, not a function
104
+
105
+ @Serializable data class Body(val name: String, val email: String)
106
+ @Serializable data class Response(val id: String)
107
+ }
108
+ }
109
+ ```
110
+
111
+ ### Swift
112
+
113
+ Same structure using case-less `enum` as a namespace idiom. One file per scope.
114
+
115
+ ```swift
116
+ // Users.swift — generated by ts-procedures-codegen, do not edit
117
+ import Foundation
118
+
119
+ public enum Users {
120
+ public enum GetUser {
121
+ public static let method = "GET"
122
+ public static let pathTemplate = "/users/{id}"
123
+ public static func path(_ p: PathParams) -> String { "/users/\(p.id)" }
124
+
125
+ public struct PathParams: Codable {
126
+ public let id: String
127
+ public init(id: String) { self.id = id }
128
+ }
129
+
130
+ public struct Response: Codable {
131
+ public let id: String
132
+ public let name: String
133
+ public let address: Address
134
+ }
135
+
136
+ public struct Address: Codable {
137
+ public let street: String
138
+ public let city: String
139
+ }
140
+
141
+ public enum Errors {
142
+ public struct NotFound: Codable, Error {
143
+ public let name: String
144
+ public let message: String
145
+ }
146
+ }
147
+ }
148
+
149
+ public enum CreateUser {
150
+ public static let method = "POST"
151
+ public static let path = "/users"
152
+
153
+ public struct Body: Codable {
154
+ public let name: String
155
+ public let email: String
156
+ }
157
+ public struct Response: Codable { public let id: String }
158
+ }
159
+ }
160
+ ```
161
+
162
+ ### Consumer usage (illustrative, not generated)
163
+
164
+ ```kotlin
165
+ // downstream Android repo
166
+ suspend fun loadUser(id: String): Users.GetUser.Response {
167
+ val path = Users.GetUser.path(Users.GetUser.PathParams(id))
168
+ val raw = myHttpClient.request(method = Users.GetUser.method, path = "$baseUrl$path")
169
+ return Json.decodeFromString(raw.body)
170
+ }
171
+ ```
172
+
173
+ ```swift
174
+ // downstream iOS repo
175
+ func loadUser(id: String) async throws -> Users.GetUser.Response {
176
+ let path = Users.GetUser.path(.init(id: id))
177
+ let data = try await myHTTP.get(baseURL + path)
178
+ return try JSONDecoder().decode(Users.GetUser.Response.self, from: data)
179
+ }
180
+ ```
181
+
182
+ ## ajsc contract
183
+
184
+ The single most important artifact for cross-team coordination. ajsc adds two emitter entry points; ts-procedures depends on them.
185
+
186
+ ### API
187
+
188
+ ```ts
189
+ // ajsc/kotlin
190
+ emitKotlin(schema: JSONSchema, opts: KotlinEmitOptions): EmitResult
191
+
192
+ // ajsc/swift
193
+ emitSwift(schema: JSONSchema, opts: SwiftEmitOptions): EmitResult
194
+
195
+ interface EmitResult {
196
+ /** Target-language source (no surrounding namespace; ts-procedures wraps). */
197
+ code: string
198
+ /** Name of the top-level type emitted, derived from opts.rootTypeName. */
199
+ rootTypeName: string
200
+ /** Names of all extracted sub-types (for caller reference resolution). */
201
+ extractedTypeNames: string[]
202
+ /** Required import lines for this output (e.g., kotlinx.serialization.*). */
203
+ imports: string[]
204
+ }
205
+
206
+ interface KotlinEmitOptions {
207
+ /** Caller-supplied root type name (e.g., "Response", "PathParams"). */
208
+ rootTypeName: string
209
+ /** Default false. When false, nested objects extract to named siblings. */
210
+ inlineTypes?: boolean
211
+ /** Phase 1: 'kotlinx' only. Reserved: 'moshi', 'none'. */
212
+ serializer?: 'kotlinx'
213
+ // carried over from existing TS-side ajsc options:
214
+ enumStyle?: ...
215
+ depluralize?: boolean
216
+ arrayItemNaming?: ...
217
+ uncountableWords?: string[]
218
+ }
219
+
220
+ interface SwiftEmitOptions {
221
+ rootTypeName: string
222
+ inlineTypes?: boolean
223
+ // Codable is the only target; no serializer flag.
224
+ enumStyle?: ...
225
+ depluralize?: boolean
226
+ arrayItemNaming?: ...
227
+ uncountableWords?: string[]
228
+ }
229
+ ```
230
+
231
+ ### Why the return shape matters
232
+
233
+ ts-procedures must reference emitted type names (e.g., the path-builder function takes `PathParams`; the wrapping `object GetUser` body lists each emitted name). Returning `rootTypeName` and `extractedTypeNames` explicitly avoids string-parsing ajsc's output and survives changes to ajsc's formatting.
234
+
235
+ ### Type-mapping specification (Kotlin)
236
+
237
+ | JSON Schema | Kotlin | Notes |
238
+ |---|---|---|
239
+ | `string` | `String` | |
240
+ | `string` + `format: "date-time"` | `String` (phase 1) | `kotlinx.datetime.Instant` reserved as opt-in. Default `String` to avoid forcing the `kotlinx-datetime` dependency on consumers. |
241
+ | `string` + `format: "uuid"` | `String` | No widely-adopted UUID type in kotlinx. |
242
+ | `integer` | `Long` | Safer default for backend IDs. `Int` not chosen because JSON Schema `integer` carries no size info. |
243
+ | `number` | `Double` | |
244
+ | `boolean` | `Boolean` | |
245
+ | `array` | `List<T>` | |
246
+ | `object` | `@Serializable data class` | |
247
+ | `enum` (string) | `enum class` with `@SerialName(...)` per variant | |
248
+ | `oneOf` with discriminator | `@Serializable sealed class` with `@JsonClassDiscriminator(...)` | |
249
+ | `oneOf` without discriminator | Phase 1: emit warning + `@Polymorphic Any`. Reconsider in phase 2. | |
250
+ | `type: ["X", "null"]` / `nullable: true` | `T?` | |
251
+ | Required vs optional property | Required → non-null; optional → nullable with `= null` default | |
252
+
253
+ ### Type-mapping specification (Swift)
254
+
255
+ | JSON Schema | Swift | Notes |
256
+ |---|---|---|
257
+ | `string` | `String` | |
258
+ | `string` + `format: "date-time"` | `String` (phase 1) | `Date` opt-in via flag once `Codable` strategies are settled. |
259
+ | `string` + `format: "uuid"` | `String` | `UUID` opt-in. |
260
+ | `integer` | `Int64` | Mirrors Kotlin `Long` choice. |
261
+ | `number` | `Double` | |
262
+ | `boolean` | `Bool` | |
263
+ | `array` | `[T]` | |
264
+ | `object` | `struct ... : Codable` | |
265
+ | `enum` (string) | `enum: String, Codable` | |
266
+ | `oneOf` with discriminator | `enum` with associated values + custom `Codable` impl | |
267
+ | `oneOf` without discriminator | Phase 1: warning + emit unsupported marker. | |
268
+ | `type: ["X", "null"]` / `nullable: true` | `T?` | |
269
+ | Required vs optional property | Required → non-optional; optional → `T?` | |
270
+
271
+ ### Naming rules
272
+
273
+ - Root type: caller passes via `opts.rootTypeName`. ajsc never infers from `schema.title`.
274
+ - Sub-types: derived from the parent property name via existing `depluralize`/`arrayItemNaming` logic. Collision suffixes appended deterministically.
275
+ - Output must be deterministic across runs so the source-hash staleness check continues to work.
276
+
277
+ ## ts-procedures-side scope
278
+
279
+ ### Pipeline extension
280
+
281
+ Extend `src/codegen/pipeline.ts` to dispatch on a target argument:
282
+
283
+ ```
284
+ DocEnvelope
285
+ → resolveEnvelope (shared)
286
+ → groupRoutesByScope (shared)
287
+ → for each target in {ts, kotlin, swift}:
288
+ → emitScopeFile<target> per scope
289
+ → emitErrorsFile (TS only — Kotlin/Swift errors live inline per-route, no registry)
290
+ → emitIndexFile (TS only — Kotlin/Swift have no factory)
291
+ → write
292
+ ```
293
+
294
+ ### Directory layout
295
+
296
+ ```
297
+ src/codegen/
298
+ emit-types.ts # existing TS emitter (keep)
299
+ emit-scope.ts # existing TS scope emitter (keep)
300
+ ...
301
+ targets/
302
+ kotlin/
303
+ emit-scope-kotlin.ts # wraps ajsc.emitKotlin into `object Scope { object Route { ... } }`
304
+ emit-scope-kotlin.test.ts
305
+ swift/
306
+ emit-scope-swift.ts
307
+ emit-scope-swift.test.ts
308
+ pipeline.ts # target-aware dispatcher
309
+ bin/cli.ts # adds --target flag
310
+ ```
311
+
312
+ The `targets/` directory is structured so a future split into sibling packages (`@ts-procedures/codegen-kotlin`, `@ts-procedures/codegen-swift`) is mechanical: each subdirectory is internally self-contained and depends only on shared core utilities.
313
+
314
+ ### CLI
315
+
316
+ Single binary, multi-target via `--target`:
317
+
318
+ ```bash
319
+ npx ts-procedures-codegen --target kotlin --url https://api.example.com/_ts-procedures.json --out ./generated-kotlin
320
+ npx ts-procedures-codegen --target swift --file ./envelope.json --out ./generated-swift
321
+ ```
322
+
323
+ Default `--target ts` preserves backward compatibility — every existing invocation continues to work unchanged.
324
+
325
+ Target-specific flags:
326
+
327
+ - Kotlin: `--kotlin-package <com.example.api>` — sets the `package` declaration on every emitted file. Required, **unless supplied via the config file's `kotlin.package`**. CLI flag and config value are interchangeable; CLI overrides config when both are present. The codegen rejects invocations where neither path provides a package.
328
+ - Swift: `--swift-module-name <name>` — currently informational (Swift does not have file-level module declarations); may drive output directory naming.
329
+
330
+ Flags inherited from existing CLI: `--watch`, `--dry-run`, `--clean-out-dir`, `--config`, `--enum-style`, `--depluralize`, `--array-item-naming`, `--uncountable-words`.
331
+
332
+ `--service-name` (existing TS flag) is **not applicable** to Kotlin/Swift targets — those targets emit no service-level namespace, factory, or aggregate index. The flag is silently ignored when `--target` is `kotlin` or `swift`. (The Kotlin scope namespace is named after the route's scope, not the service.)
333
+
334
+ Flags **not applicable** to Kotlin/Swift targets (silently ignored or rejected): `--self-contained`, `--namespace-types` (always on for Kotlin/Swift; flat mode unsupported), `--client-import-path`, `--jsdoc`, `--service-name`.
335
+
336
+ Config file: existing `ts-procedures-codegen.config.json` extended with optional `kotlin` and `swift` sub-objects:
337
+
338
+ ```json
339
+ {
340
+ "target": "kotlin",
341
+ "url": "https://api.example.com/_ts-procedures.json",
342
+ "out": "./generated",
343
+ "kotlin": { "package": "com.example.api" }
344
+ }
345
+ ```
346
+
347
+ ### Source-hash staleness
348
+
349
+ The existing source-hash-as-comment mechanism extends naturally — Kotlin and Swift both support line comments (`// ...`). ts-procedures injects `// source-hash: <hash>` at the top of each generated file; rerunning with an unchanged envelope produces a no-op diff.
350
+
351
+ ## Testing strategy
352
+
353
+ ### ajsc side
354
+
355
+ - Acceptance corpus: representative JSON Schemas + golden Kotlin and Swift outputs (`fixtures/<name>.kotlin.txt`, `fixtures/<name>.swift.txt`). Tests assert byte-identical output.
356
+ - Corpus seeded from ts-procedures' existing codegen test envelopes plus pathological cases: deeply nested objects, discriminated unions, self-referencing `$ref`, large enums, every nullable combination, arrays of arrays.
357
+
358
+ ### ts-procedures side
359
+
360
+ - Unit tests for `emit-scope-kotlin.ts` and `emit-scope-swift.ts`: given a fake `groupedRoutes` + a stubbed ajsc, assert the wrapping output (object/enum nesting, method constants, path templates, path-builder shapes).
361
+ - Integration test: feed a known DocEnvelope through the full pipeline with a real ajsc dependency; assert generated file contents against golden files.
362
+ - E2E test (Kotlin): generate output, place in a small Gradle project, compile with `kotlinc`. Asserts output is syntactically valid and imports resolve.
363
+ - E2E test (Swift): generate output, place in a small SwiftPM package, compile with `swift build`. Same syntactic-validity guarantee.
364
+
365
+ The compile-check E2E tests are essential because Kotlin/Swift are statically typed and the only way to catch "ajsc emitted a name that ts-procedures referenced incorrectly" is to compile.
366
+
367
+ **Toolchain availability.** `kotlinc` and `swift build` are not currently part of this repo's CI environment. Plan options: (a) provision both toolchains in CI (Kotlin via the `kotlin/` GitHub Action, Swift via macOS runners or `swift-actions/setup-swift`); (b) gate the compile-check E2E tests behind an opt-in flag and run them locally / in a separate scheduled CI job. Decision deferred to the implementation plan; either path is acceptable, but at minimum a manual local run must be performed before each Kotlin/Swift release.
368
+
369
+ ## Sequencing
370
+
371
+ | Phase | Owner | Deliverable |
372
+ |---|---|---|
373
+ | **A** | ajsc | `emitKotlin` exposed, type-mapping spec implemented, fixture corpus with golden outputs, semver minor bump. |
374
+ | **B** | ts-procedures | `--target kotlin` end-to-end: scope wrapper, path builders, method constants, CLI flag, config-file integration, kotlinc E2E test. |
375
+ | **C** | ajsc | `emitSwift` exposed, Swift fixture corpus. |
376
+ | **D** | ts-procedures | `--target swift` end-to-end: scope wrapper, swift build E2E test. |
377
+
378
+ Phases A→B and C→D are sequential within each target. Kotlin (A+B) ships fully before Swift work begins to avoid co-designing two emitters before either has shipped to a real consumer.
379
+
380
+ ### Coordination
381
+
382
+ - ajsc ships a `0.x` minor with each emitter; ts-procedures pins the minor range.
383
+ - Sample fixtures live in ajsc; ts-procedures duplicates a small subset for its own integration tests so the two repos cannot drift silently.
384
+
385
+ ## Open questions / explicit deferrals
386
+
387
+ 1. **Cross-route shared types** (e.g., a `User` type referenced by multiple routes via `$ref`). Phase 1 emits per-route copies, matching current TS behavior. Cross-route dedup (`Users.Shared.User` or top-level `Shared.User`) is a candidate refinement once consumer pain is known.
388
+ 2. **`format: "date-time"` → `Instant` / `Date` upgrade.** Default to `String` in phase 1 to avoid forcing kotlinx-datetime / Date-strategy decisions on consumers; promote to typed when consumer demand is concrete.
389
+ 3. **`oneOf` without discriminator.** Phase 1 emits a warning and a fallback; not all backends produce well-discriminated unions.
390
+ 4. **Per-call options.** Deferred entirely — no `signal`, `headers`, `timeout`, `meta`. If consumers ask, the additive surface (an optional second parameter to the path builder, or an opaque `RequestOptions` struct) is small enough to add later without breaking existing output.
391
+ 5. **Sub-type extraction depth.** `inlineTypes: false` is the default; whether to expose a CLI flag to override is deferred until someone asks.
392
+
393
+ ## Risk assessment
394
+
395
+ - **Risk: ajsc emitter output and ts-procedures wrapping drift.** Mitigated by the explicit `EmitResult.rootTypeName` + `extractedTypeNames` contract — no string-parsing of ajsc output — and by the kotlinc/swift-build E2E compile checks.
396
+ - **Risk: scope creep into a full mobile client (adapter, errors registry, hooks).** Mitigated by the explicit non-goals list above. Future work goes through a new spec, not a quiet expansion of this one.
397
+ - **Risk: kotlinx.serialization adoption resistance.** A `serializer: 'none'` mode is **reserved for a future phase** (not part of phase 1) — punting type annotations entirely is a one-flag escape if a consumer team's ecosystem cannot accept the kotlinx Gradle plugin. Phase 1 ships kotlinx-only; broadening is additive.
398
+
399
+ ## Summary
400
+
401
+ Add two new emitters to `ajsc` (Kotlin then Swift) and one new `--target` mode to ts-procedures codegen. Generated output is **types + URL/method primitives only** — no runtime, no adapter, no registry. Mobile consumers receive a typed, structured catalog of the server's contract and integrate it into whatever HTTP stack they already use. The non-invasive surface keeps maintenance burden low and lets Android/iOS teams keep their existing networking architecture intact.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "6.0.0",
3
+ "version": "6.0.2",
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",
@@ -234,6 +234,8 @@ const docs = builder.docs
234
234
 
235
235
  All four builders' `build()` methods are synchronous — they return the framework app instance directly. Don't `await` the call.
236
236
 
237
+ **Single Hono server across builders:** all three Hono builders (`HonoRPCAppBuilder`, `HonoAPIAppBuilder`, `HonoStreamAppBuilder`) accept an optional `app?: Hono` in their config. Pass the same `new Hono()` instance to mount RPC, API, and Stream routes onto one server — the builders are registration scopes, not separate servers. See **[docs/http-integrations.md § One Hono Server, Multiple Builders](../../../docs/http-integrations.md#one-hono-server-multiple-builders)**.
238
+
237
239
  **Key methods:**
238
240
 
239
241
  | Method | Returns | Description |
@@ -257,8 +259,7 @@ import { DocRegistry } from 'ts-procedures/http-docs'
257
259
 
258
260
  const docs = new DocRegistry({
259
261
  basePath: '/api',
260
- headers: [{ name: 'Authorization', description: 'Bearer token', required: false }],
261
- errors: DocRegistry.defaultErrors(),
262
+ errors: appErrors, // your ErrorTaxonomy framework defaults auto-merged and deduped
262
263
  })
263
264
  .from(rpcBuilder)
264
265
  .from(apiBuilder)
@@ -269,7 +270,7 @@ app.get('/docs', (c) => c.json(docs.toJSON()))
269
270
 
270
271
  - `from()` stores a reference — routes are read lazily at `toJSON()` time
271
272
  - `toJSON()` supports optional `filter` and `transform` options
272
- - `defaultErrors()` returns error schemas for all 4 procedure error types
273
+ - `errors` accepts an `ErrorTaxonomy` or raw `ErrorDoc[]`; framework defaults are auto-merged (opt out via `includeDefaults: false`)
273
274
  - All builders satisfy the `DocSource` interface (`{ readonly docs: AnyHttpRouteDoc[] }`)
274
275
 
275
276
  ### Client Code Generation
@@ -3,6 +3,7 @@ import { v } from 'suretype'
3
3
  import { Procedures } from '../../index.js'
4
4
  import { HonoRPCAppBuilder } from './hono-rpc/index.js'
5
5
  import { DocRegistry } from './doc-registry.js'
6
+ import { defineErrorTaxonomy } from './error-taxonomy.js'
6
7
  import type {
7
8
  AnyHttpRouteDoc,
8
9
  RPCHttpRouteDoc,
@@ -11,6 +12,7 @@ import type {
11
12
  StreamHttpRouteDoc,
12
13
  DocSource,
13
14
  DocEnvelope,
15
+ ErrorDoc,
14
16
  } from '../types.js'
15
17
 
16
18
  // ---------------------------------------------------------------------------
@@ -61,7 +63,7 @@ describe('DocRegistry', () => {
61
63
  // --------------------------------------------------------------------------
62
64
  describe('constructor', () => {
63
65
  test('uses defaults when no config provided', () => {
64
- const registry = new DocRegistry()
66
+ const registry = new DocRegistry({ includeDefaults: false })
65
67
  const out = registry.toJSON()
66
68
  expect(out.basePath).toBe('')
67
69
  expect(out.headers).toEqual([])
@@ -69,7 +71,7 @@ describe('DocRegistry', () => {
69
71
  })
70
72
 
71
73
  test('accepts partial config', () => {
72
- const registry = new DocRegistry({ basePath: '/v1' })
74
+ const registry = new DocRegistry({ basePath: '/v1', includeDefaults: false })
73
75
  const out = registry.toJSON()
74
76
  expect(out.basePath).toBe('/v1')
75
77
  expect(out.headers).toEqual([])
@@ -79,7 +81,7 @@ describe('DocRegistry', () => {
79
81
  test('accepts full config', () => {
80
82
  const headers = [{ name: 'Authorization', description: 'Bearer token', required: true }]
81
83
  const errors = [{ name: 'Unauthorized', statusCode: 401, description: 'Missing token' }]
82
- const registry = new DocRegistry({ basePath: '/api', headers, errors })
84
+ const registry = new DocRegistry({ basePath: '/api', headers, errors, includeDefaults: false })
83
85
  const out = registry.toJSON()
84
86
  expect(out.basePath).toBe('/api')
85
87
  expect(out.headers).toEqual(headers)
@@ -175,7 +177,7 @@ describe('DocRegistry', () => {
175
177
  test('headers and errors are copies', () => {
176
178
  const headers = [{ name: 'X-Custom' }]
177
179
  const errors = [{ name: 'E', statusCode: 500, description: 'd' }]
178
- const registry = new DocRegistry({ headers, errors })
180
+ const registry = new DocRegistry({ headers, errors, includeDefaults: false })
179
181
  const out = registry.toJSON()
180
182
  expect(out.headers).toEqual(headers)
181
183
  expect(out.headers).not.toBe(headers)
@@ -298,6 +300,153 @@ describe('DocRegistry', () => {
298
300
  })
299
301
  })
300
302
 
303
+ // --------------------------------------------------------------------------
304
+ // taxonomy input + auto-defaults + dedupe (v6.0.1 simplification)
305
+ // --------------------------------------------------------------------------
306
+ describe('errors: ErrorTaxonomy (polymorphic constructor input)', () => {
307
+ test('accepts an ErrorTaxonomy directly and converts to ErrorDoc[]', () => {
308
+ const taxonomy = defineErrorTaxonomy({
309
+ AuthError: {
310
+ class: class AuthError extends Error {},
311
+ statusCode: 401,
312
+ description: 'unauthenticated',
313
+ },
314
+ })
315
+ const envelope = new DocRegistry({ errors: taxonomy }).toJSON()
316
+ const auth = envelope.errors.find((e) => e.name === 'AuthError')
317
+ expect(auth).toBeDefined()
318
+ expect(auth?.statusCode).toBe(401)
319
+ expect(auth?.description).toBe('unauthenticated')
320
+ })
321
+
322
+ test('auto-includes framework defaults when errors is a taxonomy', () => {
323
+ const taxonomy = defineErrorTaxonomy({
324
+ AuthError: { class: class AuthError extends Error {}, statusCode: 401 },
325
+ })
326
+ const names = new DocRegistry({ errors: taxonomy }).toJSON().errors.map((e) => e.name)
327
+ expect(names).toContain('AuthError')
328
+ expect(names).toContain('ProcedureValidationError')
329
+ expect(names).toContain('ProcedureYieldValidationError')
330
+ expect(names).toContain('ProcedureError')
331
+ expect(names).toContain('ProcedureRegistrationError')
332
+ })
333
+
334
+ test('auto-includes framework defaults when errors is ErrorDoc[]', () => {
335
+ const custom: ErrorDoc = { name: 'CustomThing', statusCode: 418, description: 'teapot' }
336
+ const names = new DocRegistry({ errors: [custom] }).toJSON().errors.map((e) => e.name)
337
+ expect(names).toContain('CustomThing')
338
+ expect(names).toContain('ProcedureValidationError')
339
+ })
340
+
341
+ test('includeDefaults: false omits framework defaults', () => {
342
+ const taxonomy = defineErrorTaxonomy({
343
+ AuthError: { class: class AuthError extends Error {}, statusCode: 401 },
344
+ })
345
+ const names = new DocRegistry({ errors: taxonomy, includeDefaults: false })
346
+ .toJSON()
347
+ .errors.map((e) => e.name)
348
+ expect(names).toEqual(['AuthError'])
349
+ })
350
+
351
+ test('user taxonomy entry with same name as default overrides default (dedupe, user wins)', () => {
352
+ const taxonomy = defineErrorTaxonomy({
353
+ ProcedureError: {
354
+ class: Error,
355
+ statusCode: 418,
356
+ description: 'custom override',
357
+ },
358
+ })
359
+ const envelope = new DocRegistry({ errors: taxonomy }).toJSON()
360
+ const proc = envelope.errors.filter((e) => e.name === 'ProcedureError')
361
+ expect(proc).toHaveLength(1)
362
+ expect(proc[0]!.statusCode).toBe(418)
363
+ expect(proc[0]!.description).toBe('custom override')
364
+ })
365
+
366
+ test('user ErrorDoc with same name as default overrides default (dedupe, user wins)', () => {
367
+ const custom: ErrorDoc = {
368
+ name: 'ProcedureError',
369
+ statusCode: 418,
370
+ description: 'custom override',
371
+ }
372
+ const envelope = new DocRegistry({ errors: [custom] }).toJSON()
373
+ const proc = envelope.errors.filter((e) => e.name === 'ProcedureError')
374
+ expect(proc).toHaveLength(1)
375
+ expect(proc[0]!.statusCode).toBe(418)
376
+ expect(proc[0]!.description).toBe('custom override')
377
+ })
378
+
379
+ test('empty errors config still returns framework defaults', () => {
380
+ const names = new DocRegistry().toJSON().errors.map((e) => e.name)
381
+ expect(names).toContain('ProcedureError')
382
+ expect(names).toContain('ProcedureValidationError')
383
+ expect(names).toContain('ProcedureYieldValidationError')
384
+ expect(names).toContain('ProcedureRegistrationError')
385
+ })
386
+
387
+ test('includeDefaults: false with no errors produces empty error list', () => {
388
+ const envelope = new DocRegistry({ includeDefaults: false }).toJSON()
389
+ expect(envelope.errors).toEqual([])
390
+ })
391
+ })
392
+
393
+ // --------------------------------------------------------------------------
394
+ // .documentError() fluent extension
395
+ // --------------------------------------------------------------------------
396
+ describe('.documentError()', () => {
397
+ test('adds a single ErrorDoc to the envelope', () => {
398
+ const registry = new DocRegistry({ includeDefaults: false }).documentError({
399
+ name: 'RateLimitExceeded',
400
+ statusCode: 429,
401
+ description: 'too many requests',
402
+ })
403
+ const names = registry.toJSON().errors.map((e) => e.name)
404
+ expect(names).toEqual(['RateLimitExceeded'])
405
+ })
406
+
407
+ test('accepts multiple docs via variadic args', () => {
408
+ const registry = new DocRegistry({ includeDefaults: false }).documentError(
409
+ { name: 'A', statusCode: 400, description: 'a' },
410
+ { name: 'B', statusCode: 500, description: 'b' }
411
+ )
412
+ const names = registry.toJSON().errors.map((e) => e.name)
413
+ expect(names).toEqual(['A', 'B'])
414
+ })
415
+
416
+ test('returns this for chaining', () => {
417
+ const registry = new DocRegistry()
418
+ const returned = registry.documentError({
419
+ name: 'Foo',
420
+ statusCode: 500,
421
+ description: 'x',
422
+ })
423
+ expect(returned).toBe(registry)
424
+ })
425
+
426
+ test('composes with taxonomy input — extends docs without replacing', () => {
427
+ const taxonomy = defineErrorTaxonomy({
428
+ AuthError: { class: class AuthError extends Error {}, statusCode: 401 },
429
+ })
430
+ const envelope = new DocRegistry({ errors: taxonomy })
431
+ .documentError({ name: 'RateLimitExceeded', statusCode: 429, description: 'x' })
432
+ .toJSON()
433
+ const names = envelope.errors.map((e) => e.name)
434
+ expect(names).toContain('AuthError')
435
+ expect(names).toContain('RateLimitExceeded')
436
+ expect(names).toContain('ProcedureValidationError')
437
+ })
438
+
439
+ test('dedupes against existing errors (last write wins)', () => {
440
+ const registry = new DocRegistry({ includeDefaults: false })
441
+ .documentError({ name: 'Foo', statusCode: 400, description: 'first' })
442
+ .documentError({ name: 'Foo', statusCode: 500, description: 'second' })
443
+ const foo = registry.toJSON().errors.filter((e) => e.name === 'Foo')
444
+ expect(foo).toHaveLength(1)
445
+ expect(foo[0]!.statusCode).toBe(500)
446
+ expect(foo[0]!.description).toBe('second')
447
+ })
448
+ })
449
+
301
450
  // --------------------------------------------------------------------------
302
451
  // kind discriminant
303
452
  // --------------------------------------------------------------------------
@@ -333,7 +482,6 @@ describe('DocRegistry', () => {
333
482
  const registry = new DocRegistry({
334
483
  basePath: '/api',
335
484
  headers: [{ name: 'Authorization', description: 'Bearer token', required: false }],
336
- errors: DocRegistry.defaultErrors(),
337
485
  })
338
486
  .from(makeSource([rpcDoc]))
339
487
  .from(makeSource([apiDoc]))
@@ -347,10 +495,7 @@ describe('DocRegistry', () => {
347
495
  })
348
496
 
349
497
  test('filter + transform combined', () => {
350
- const registry = new DocRegistry({
351
- basePath: '/api',
352
- errors: DocRegistry.defaultErrors(),
353
- })
498
+ const registry = new DocRegistry({ basePath: '/api' })
354
499
  .from(makeSource([rpcDoc]))
355
500
  .from(makeSource([apiDoc]))
356
501
  .from(makeSource([streamDoc]))
@@ -372,7 +517,6 @@ describe('DocRegistry', () => {
372
517
  const registry = new DocRegistry({
373
518
  basePath: '/api',
374
519
  headers: [{ name: 'X-Request-Id', example: 'abc-123' }],
375
- errors: DocRegistry.defaultErrors(),
376
520
  })
377
521
  .from(makeSource([rpcDoc]))
378
522
  .from(makeSource([apiDoc]))