ts-procedures 7.3.0 → 8.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 (332) hide show
  1. package/README.md +65 -3
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +6 -8
  3. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +30 -33
  4. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +104 -53
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +205 -232
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +80 -153
  7. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
  8. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +4 -5
  9. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +4 -7
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono.md +223 -0
  11. package/agent_config/copilot/copilot-instructions.md +34 -48
  12. package/agent_config/cursor/cursorrules +34 -48
  13. package/build/client/call.js +4 -1
  14. package/build/client/call.js.map +1 -1
  15. package/build/client/call.test.js +23 -0
  16. package/build/client/call.test.js.map +1 -1
  17. package/build/client/fetch-adapter.js +3 -1
  18. package/build/client/fetch-adapter.js.map +1 -1
  19. package/build/client/fetch-adapter.test.js +11 -1
  20. package/build/client/fetch-adapter.test.js.map +1 -1
  21. package/build/client/index.test.js +7 -7
  22. package/build/client/index.test.js.map +1 -1
  23. package/build/client/request-builder.d.ts +1 -1
  24. package/build/client/request-builder.js +2 -2
  25. package/build/client/request-builder.js.map +1 -1
  26. package/build/client/stream.js +13 -2
  27. package/build/client/stream.js.map +1 -1
  28. package/build/client/stream.test.js +32 -7
  29. package/build/client/stream.test.js.map +1 -1
  30. package/build/client/typed-error-dispatch.test.js +8 -92
  31. package/build/client/typed-error-dispatch.test.js.map +1 -1
  32. package/build/client/types.d.ts +21 -3
  33. package/build/codegen/bin/cli.js +0 -0
  34. package/build/codegen/e2e.test.js +418 -23
  35. package/build/codegen/e2e.test.js.map +1 -1
  36. package/build/codegen/emit-errors.integration.test.js +1 -1
  37. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  38. package/build/codegen/emit-scope.js +351 -55
  39. package/build/codegen/emit-scope.js.map +1 -1
  40. package/build/codegen/emit-scope.test.js +540 -110
  41. package/build/codegen/emit-scope.test.js.map +1 -1
  42. package/build/codegen/emit-types.d.ts +6 -2
  43. package/build/codegen/emit-types.js +81 -20
  44. package/build/codegen/emit-types.js.map +1 -1
  45. package/build/codegen/emit-types.test.js +70 -1
  46. package/build/codegen/emit-types.test.js.map +1 -1
  47. package/build/codegen/pipeline.test.js +7 -7
  48. package/build/codegen/pipeline.test.js.map +1 -1
  49. package/build/codegen/resolve-envelope.js +1 -1
  50. package/build/codegen/resolve-envelope.js.map +1 -1
  51. package/build/codegen/resolve-envelope.test.js +5 -5
  52. package/build/codegen/resolve-envelope.test.js.map +1 -1
  53. package/build/codegen/targets/_shared/route-slots.d.ts +8 -3
  54. package/build/codegen/targets/_shared/route-slots.js +49 -8
  55. package/build/codegen/targets/_shared/route-slots.js.map +1 -1
  56. package/build/codegen/targets/_shared/route-slots.test.js +99 -26
  57. package/build/codegen/targets/_shared/route-slots.test.js.map +1 -1
  58. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js +88 -17
  59. package/build/codegen/targets/kotlin/emit-route-kotlin.test.js.map +1 -1
  60. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js +9 -6
  61. package/build/codegen/targets/kotlin/emit-scope-kotlin.test.js.map +1 -1
  62. package/build/codegen/targets/kotlin/integration.test.js +6 -0
  63. package/build/codegen/targets/kotlin/integration.test.js.map +1 -1
  64. package/build/codegen/targets/swift/access-level.test.js +8 -11
  65. package/build/codegen/targets/swift/access-level.test.js.map +1 -1
  66. package/build/codegen/targets/swift/emit-route-swift.test.js +91 -20
  67. package/build/codegen/targets/swift/emit-route-swift.test.js.map +1 -1
  68. package/build/codegen/targets/swift/emit-scope-swift.test.js +12 -9
  69. package/build/codegen/targets/swift/emit-scope-swift.test.js.map +1 -1
  70. package/build/codegen/targets/swift/integration.test.js +6 -0
  71. package/build/codegen/targets/swift/integration.test.js.map +1 -1
  72. package/build/create-http-stream.d.ts +58 -0
  73. package/build/create-http-stream.js +122 -0
  74. package/build/create-http-stream.js.map +1 -0
  75. package/build/create-http-stream.test.js +88 -0
  76. package/build/create-http-stream.test.js.map +1 -0
  77. package/build/create-http.d.ts +49 -0
  78. package/build/create-http.js +108 -0
  79. package/build/create-http.js.map +1 -0
  80. package/build/create-http.test.js +137 -0
  81. package/build/create-http.test.js.map +1 -0
  82. package/build/create-stream.d.ts +35 -0
  83. package/build/create-stream.js +123 -0
  84. package/build/create-stream.js.map +1 -0
  85. package/build/create-stream.test.js +428 -0
  86. package/build/create-stream.test.js.map +1 -0
  87. package/build/create.d.ts +28 -0
  88. package/build/create.js +82 -0
  89. package/build/create.js.map +1 -0
  90. package/build/create.test.js +483 -0
  91. package/build/create.test.js.map +1 -0
  92. package/build/exports.d.ts +2 -0
  93. package/build/implementations/http/astro/index.test.js +20 -12
  94. package/build/implementations/http/astro/index.test.js.map +1 -1
  95. package/build/implementations/http/doc-registry.js +1 -1
  96. package/build/implementations/http/doc-registry.js.map +1 -1
  97. package/build/implementations/http/doc-registry.test.js +36 -5
  98. package/build/implementations/http/doc-registry.test.js.map +1 -1
  99. package/build/implementations/http/error-dispatch.d.ts +76 -0
  100. package/build/implementations/http/error-dispatch.js +77 -0
  101. package/build/implementations/http/error-dispatch.js.map +1 -0
  102. package/build/implementations/http/error-dispatch.test.js +254 -0
  103. package/build/implementations/http/error-dispatch.test.js.map +1 -0
  104. package/build/implementations/http/error-taxonomy.d.ts +5 -5
  105. package/build/implementations/http/hono/docs/http-doc.d.ts +6 -0
  106. package/build/implementations/http/hono/docs/http-doc.js +42 -0
  107. package/build/implementations/http/hono/docs/http-doc.js.map +1 -0
  108. package/build/implementations/http/hono/docs/http-stream-doc.d.ts +6 -0
  109. package/build/implementations/http/hono/docs/http-stream-doc.js +40 -0
  110. package/build/implementations/http/hono/docs/http-stream-doc.js.map +1 -0
  111. package/build/implementations/http/hono/docs/rpc-doc.d.ts +6 -0
  112. package/build/implementations/http/hono/docs/rpc-doc.js +24 -0
  113. package/build/implementations/http/hono/docs/rpc-doc.js.map +1 -0
  114. package/build/implementations/http/hono/docs/stream-doc.d.ts +6 -0
  115. package/build/implementations/http/hono/docs/stream-doc.js +42 -0
  116. package/build/implementations/http/hono/docs/stream-doc.js.map +1 -0
  117. package/build/implementations/http/hono/handlers/http-stream.d.ts +10 -0
  118. package/build/implementations/http/hono/handlers/http-stream.js +123 -0
  119. package/build/implementations/http/hono/handlers/http-stream.js.map +1 -0
  120. package/build/implementations/http/hono/handlers/http-stream.test.js +128 -0
  121. package/build/implementations/http/hono/handlers/http-stream.test.js.map +1 -0
  122. package/build/implementations/http/hono/handlers/http.d.ts +10 -0
  123. package/build/implementations/http/hono/handlers/http.js +115 -0
  124. package/build/implementations/http/hono/handlers/http.js.map +1 -0
  125. package/build/implementations/http/hono/handlers/http.test.js +118 -0
  126. package/build/implementations/http/hono/handlers/http.test.js.map +1 -0
  127. package/build/implementations/http/hono/handlers/rpc.d.ts +11 -0
  128. package/build/implementations/http/hono/handlers/rpc.js +32 -0
  129. package/build/implementations/http/hono/handlers/rpc.js.map +1 -0
  130. package/build/implementations/http/hono/handlers/rpc.test.js +73 -0
  131. package/build/implementations/http/hono/handlers/rpc.test.js.map +1 -0
  132. package/build/implementations/http/hono/handlers/stream.d.ts +23 -0
  133. package/build/implementations/http/hono/handlers/stream.js +147 -0
  134. package/build/implementations/http/hono/handlers/stream.js.map +1 -0
  135. package/build/implementations/http/hono/handlers/stream.test.d.ts +1 -0
  136. package/build/implementations/http/hono/handlers/stream.test.js +177 -0
  137. package/build/implementations/http/hono/handlers/stream.test.js.map +1 -0
  138. package/build/implementations/http/hono/index.d.ts +57 -0
  139. package/build/implementations/http/hono/index.js +149 -0
  140. package/build/implementations/http/hono/index.js.map +1 -0
  141. package/build/implementations/http/hono/index.test.d.ts +1 -0
  142. package/build/implementations/http/hono/index.test.js +274 -0
  143. package/build/implementations/http/hono/index.test.js.map +1 -0
  144. package/build/implementations/http/hono/path.d.ts +17 -0
  145. package/build/implementations/http/hono/path.js +39 -0
  146. package/build/implementations/http/hono/path.js.map +1 -0
  147. package/build/implementations/http/hono/path.test.d.ts +1 -0
  148. package/build/implementations/http/hono/path.test.js +83 -0
  149. package/build/implementations/http/hono/path.test.js.map +1 -0
  150. package/build/implementations/http/hono/types.d.ts +51 -0
  151. package/build/implementations/http/hono/types.js.map +1 -0
  152. package/build/implementations/http/on-request-error.test.js +6 -96
  153. package/build/implementations/http/on-request-error.test.js.map +1 -1
  154. package/build/implementations/http/route-errors.test.js +11 -59
  155. package/build/implementations/http/route-errors.test.js.map +1 -1
  156. package/build/implementations/types.d.ts +43 -9
  157. package/build/index.d.ts +124 -124
  158. package/build/index.js +10 -221
  159. package/build/index.js.map +1 -1
  160. package/build/index.test.js +20 -919
  161. package/build/index.test.js.map +1 -1
  162. package/build/migration.test.d.ts +1 -0
  163. package/build/migration.test.js +34 -0
  164. package/build/migration.test.js.map +1 -0
  165. package/build/schema/compute-schema.d.ts +11 -3
  166. package/build/schema/compute-schema.js +13 -7
  167. package/build/schema/compute-schema.js.map +1 -1
  168. package/build/schema/parser.d.ts +11 -3
  169. package/build/schema/parser.js +49 -9
  170. package/build/schema/parser.js.map +1 -1
  171. package/build/stack-utils.js +8 -0
  172. package/build/stack-utils.js.map +1 -1
  173. package/build/types.d.ts +142 -0
  174. package/build/types.js.map +1 -0
  175. package/docs/astro-adapter.md +5 -5
  176. package/docs/core.md +15 -17
  177. package/docs/http-integrations.md +83 -170
  178. package/docs/streaming.md +3 -60
  179. package/docs/superpowers/plans/2026-05-07-astro-adapter.md +2 -7
  180. package/docs/superpowers/plans/2026-05-08-create-http.md +3355 -0
  181. package/docs/superpowers/plans/2026-05-08-hono-app-builder-convergence.md +3365 -0
  182. package/docs/superpowers/specs/2026-05-07-astro-adapter-design.md +1 -3
  183. package/docs/superpowers/specs/2026-05-08-create-http-design.md +409 -0
  184. package/docs/superpowers/specs/2026-05-08-hono-app-builder-convergence-design.md +411 -0
  185. package/package.json +4 -22
  186. package/src/client/call.test.ts +26 -0
  187. package/src/client/call.ts +4 -1
  188. package/src/client/fetch-adapter.test.ts +14 -1
  189. package/src/client/fetch-adapter.ts +3 -1
  190. package/src/client/index.test.ts +7 -7
  191. package/src/client/request-builder.ts +2 -2
  192. package/src/client/stream.test.ts +39 -7
  193. package/src/client/stream.ts +16 -2
  194. package/src/client/typed-error-dispatch.test.ts +7 -97
  195. package/src/client/types.ts +21 -3
  196. package/src/codegen/__fixtures__/users-envelope.json +119 -38
  197. package/src/codegen/e2e.test.ts +452 -24
  198. package/src/codegen/emit-errors.integration.test.ts +1 -1
  199. package/src/codegen/emit-scope.test.ts +581 -110
  200. package/src/codegen/emit-scope.ts +390 -61
  201. package/src/codegen/emit-types.test.ts +73 -1
  202. package/src/codegen/emit-types.ts +82 -21
  203. package/src/codegen/pipeline.test.ts +7 -7
  204. package/src/codegen/resolve-envelope.test.ts +5 -5
  205. package/src/codegen/resolve-envelope.ts +1 -1
  206. package/src/codegen/targets/_shared/route-slots.test.ts +109 -26
  207. package/src/codegen/targets/_shared/route-slots.ts +48 -11
  208. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +73 -0
  209. package/src/codegen/targets/kotlin/emit-route-kotlin.test.ts +100 -17
  210. package/src/codegen/targets/kotlin/emit-scope-kotlin.test.ts +9 -6
  211. package/src/codegen/targets/kotlin/integration.test.ts +19 -0
  212. package/src/codegen/targets/swift/__fixtures__/users-golden.swift +79 -0
  213. package/src/codegen/targets/swift/access-level.test.ts +8 -11
  214. package/src/codegen/targets/swift/emit-route-swift.test.ts +103 -20
  215. package/src/codegen/targets/swift/emit-scope-swift.test.ts +12 -9
  216. package/src/codegen/targets/swift/integration.test.ts +17 -0
  217. package/src/create-http-stream.test.ts +97 -0
  218. package/src/create-http-stream.ts +191 -0
  219. package/src/create-http.test.ts +163 -0
  220. package/src/create-http.ts +211 -0
  221. package/src/create-stream.test.ts +565 -0
  222. package/src/create-stream.ts +228 -0
  223. package/src/create.test.ts +658 -0
  224. package/src/create.ts +172 -0
  225. package/src/exports.ts +2 -0
  226. package/src/implementations/http/README.md +135 -95
  227. package/src/implementations/http/astro/README.md +4 -5
  228. package/src/implementations/http/astro/index.test.ts +25 -18
  229. package/src/implementations/http/doc-registry.test.ts +42 -5
  230. package/src/implementations/http/doc-registry.ts +1 -1
  231. package/src/implementations/http/error-dispatch.test.ts +283 -0
  232. package/src/implementations/http/error-dispatch.ts +176 -0
  233. package/src/implementations/http/error-taxonomy.ts +5 -5
  234. package/src/implementations/http/hono/docs/http-doc.ts +43 -0
  235. package/src/implementations/http/hono/docs/http-stream-doc.ts +44 -0
  236. package/src/implementations/http/hono/docs/rpc-doc.ts +34 -0
  237. package/src/implementations/http/hono/docs/stream-doc.ts +53 -0
  238. package/src/implementations/http/hono/handlers/http-stream.test.ts +150 -0
  239. package/src/implementations/http/hono/handlers/http-stream.ts +152 -0
  240. package/src/implementations/http/hono/handlers/http.test.ts +130 -0
  241. package/src/implementations/http/hono/handlers/http.ts +147 -0
  242. package/src/implementations/http/hono/handlers/rpc.test.ts +81 -0
  243. package/src/implementations/http/hono/handlers/rpc.ts +54 -0
  244. package/src/implementations/http/hono/handlers/stream.test.ts +198 -0
  245. package/src/implementations/http/hono/handlers/stream.ts +208 -0
  246. package/src/implementations/http/hono/index.test.ts +329 -0
  247. package/src/implementations/http/hono/index.ts +204 -0
  248. package/src/implementations/http/hono/path.test.ts +96 -0
  249. package/src/implementations/http/hono/path.ts +59 -0
  250. package/src/implementations/http/hono/types.ts +93 -0
  251. package/src/implementations/http/on-request-error.test.ts +10 -116
  252. package/src/implementations/http/route-errors.test.ts +11 -77
  253. package/src/implementations/types.ts +44 -9
  254. package/src/index.test.ts +22 -1249
  255. package/src/index.ts +49 -485
  256. package/src/migration.test.ts +48 -0
  257. package/src/schema/compute-schema.ts +26 -12
  258. package/src/schema/parser.ts +62 -12
  259. package/src/stack-utils.ts +8 -0
  260. package/src/types.ts +133 -0
  261. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +0 -137
  262. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +0 -173
  263. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +0 -142
  264. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +0 -147
  265. package/build/implementations/http/express-rpc/error-taxonomy.test.js +0 -83
  266. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +0 -1
  267. package/build/implementations/http/express-rpc/index.d.ts +0 -125
  268. package/build/implementations/http/express-rpc/index.js +0 -216
  269. package/build/implementations/http/express-rpc/index.js.map +0 -1
  270. package/build/implementations/http/express-rpc/index.test.js +0 -684
  271. package/build/implementations/http/express-rpc/index.test.js.map +0 -1
  272. package/build/implementations/http/express-rpc/types.d.ts +0 -11
  273. package/build/implementations/http/express-rpc/types.js.map +0 -1
  274. package/build/implementations/http/hono-api/error-taxonomy.test.js +0 -137
  275. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +0 -1
  276. package/build/implementations/http/hono-api/index.d.ts +0 -151
  277. package/build/implementations/http/hono-api/index.js +0 -344
  278. package/build/implementations/http/hono-api/index.js.map +0 -1
  279. package/build/implementations/http/hono-api/index.test.js +0 -992
  280. package/build/implementations/http/hono-api/index.test.js.map +0 -1
  281. package/build/implementations/http/hono-api/types.d.ts +0 -13
  282. package/build/implementations/http/hono-api/types.js.map +0 -1
  283. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +0 -64
  284. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +0 -1
  285. package/build/implementations/http/hono-rpc/index.d.ts +0 -130
  286. package/build/implementations/http/hono-rpc/index.js +0 -209
  287. package/build/implementations/http/hono-rpc/index.js.map +0 -1
  288. package/build/implementations/http/hono-rpc/index.test.js +0 -828
  289. package/build/implementations/http/hono-rpc/index.test.js.map +0 -1
  290. package/build/implementations/http/hono-rpc/types.d.ts +0 -11
  291. package/build/implementations/http/hono-rpc/types.js +0 -2
  292. package/build/implementations/http/hono-rpc/types.js.map +0 -1
  293. package/build/implementations/http/hono-stream/error-taxonomy.test.js +0 -159
  294. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +0 -1
  295. package/build/implementations/http/hono-stream/index.d.ts +0 -171
  296. package/build/implementations/http/hono-stream/index.js +0 -415
  297. package/build/implementations/http/hono-stream/index.js.map +0 -1
  298. package/build/implementations/http/hono-stream/index.test.js +0 -1383
  299. package/build/implementations/http/hono-stream/index.test.js.map +0 -1
  300. package/build/implementations/http/hono-stream/types.d.ts +0 -15
  301. package/build/implementations/http/hono-stream/types.js +0 -2
  302. package/build/implementations/http/hono-stream/types.js.map +0 -1
  303. package/src/implementations/http/express-rpc/README.md +0 -280
  304. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +0 -103
  305. package/src/implementations/http/express-rpc/index.test.ts +0 -957
  306. package/src/implementations/http/express-rpc/index.ts +0 -327
  307. package/src/implementations/http/express-rpc/types.ts +0 -16
  308. package/src/implementations/http/hono-api/README.md +0 -284
  309. package/src/implementations/http/hono-api/error-taxonomy.test.ts +0 -179
  310. package/src/implementations/http/hono-api/index.test.ts +0 -1341
  311. package/src/implementations/http/hono-api/index.ts +0 -519
  312. package/src/implementations/http/hono-api/types.ts +0 -16
  313. package/src/implementations/http/hono-rpc/README.md +0 -357
  314. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +0 -82
  315. package/src/implementations/http/hono-rpc/index.test.ts +0 -1107
  316. package/src/implementations/http/hono-rpc/index.ts +0 -320
  317. package/src/implementations/http/hono-rpc/types.ts +0 -16
  318. package/src/implementations/http/hono-stream/README.md +0 -559
  319. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +0 -178
  320. package/src/implementations/http/hono-stream/index.test.ts +0 -1804
  321. package/src/implementations/http/hono-stream/index.ts +0 -622
  322. package/src/implementations/http/hono-stream/types.ts +0 -20
  323. /package/build/{implementations/http/express-rpc/error-taxonomy.test.d.ts → create-http-stream.test.d.ts} +0 -0
  324. /package/build/{implementations/http/express-rpc/index.test.d.ts → create-http.test.d.ts} +0 -0
  325. /package/build/{implementations/http/hono-api/error-taxonomy.test.d.ts → create-stream.test.d.ts} +0 -0
  326. /package/build/{implementations/http/hono-api/index.test.d.ts → create.test.d.ts} +0 -0
  327. /package/build/implementations/http/{hono-rpc/error-taxonomy.test.d.ts → error-dispatch.test.d.ts} +0 -0
  328. /package/build/implementations/http/{hono-rpc/index.test.d.ts → hono/handlers/http-stream.test.d.ts} +0 -0
  329. /package/build/implementations/http/{hono-stream/error-taxonomy.test.d.ts → hono/handlers/http.test.d.ts} +0 -0
  330. /package/build/implementations/http/{hono-stream/index.test.d.ts → hono/handlers/rpc.test.d.ts} +0 -0
  331. /package/build/implementations/http/{express-rpc → hono}/types.js +0 -0
  332. /package/build/{implementations/http/hono-api/types.js → types.js} +0 -0
@@ -3,12 +3,14 @@ import type {
3
3
  RPCHttpRouteDoc,
4
4
  APIHttpRouteDoc,
5
5
  StreamHttpRouteDoc,
6
+ HttpStreamRouteDoc,
6
7
  } from '../implementations/types.js'
7
8
  import {
8
9
  jsonSchemaToTypeString,
9
10
  jsonSchemaToTypeBody,
10
11
  jsonSchemaToExtractedTypes,
11
12
  renameExtractedTypes,
13
+ extractedDeclName,
12
14
  type AjscOptions,
13
15
  type ExtractedTypeOutput,
14
16
  } from './emit-types.js'
@@ -58,7 +60,8 @@ interface EmitRouteContext {
58
60
  * This provides backward compatibility with servers running older ts-procedures
59
61
  * versions that don't set `kind` on route docs.
60
62
  */
61
- function inferRouteKind(route: Record<string, unknown>): 'rpc' | 'api' | 'stream' {
63
+ function inferRouteKind(route: Record<string, unknown>): 'rpc' | 'api' | 'stream' | 'http-stream' {
64
+ if ('streamMode' in route && 'fullPath' in route) return 'http-stream'
62
65
  if ('streamMode' in route) return 'stream'
63
66
  if ('fullPath' in route) return 'api'
64
67
  return 'rpc'
@@ -107,6 +110,45 @@ function indent(text: string, prefix: string): string {
107
110
  return text.split('\n').map((line) => (line ? prefix + line : line)).join('\n')
108
111
  }
109
112
 
113
+ /**
114
+ * Tracks extracted declarations emitted into a single namespace, guarding
115
+ * against duplicate identifiers (defense-in-depth on top of the rename pass).
116
+ *
117
+ * - Exact-string duplicates (the same sub-type extracted from two schemas) are
118
+ * silently skipped.
119
+ * - A same-name-but-different-body declaration is a genuine collision the
120
+ * rename pass failed to resolve; emitting it would produce an opaque
121
+ * `TS2300: Duplicate identifier` in the consumer's build. We fail fast at
122
+ * codegen with a message that names the offending identifier instead.
123
+ *
124
+ * Returns the indented declaration line to push, or `null` when it should be
125
+ * skipped (exact duplicate).
126
+ */
127
+ class DeclarationCollector {
128
+ private readonly seenStrings = new Set<string>()
129
+ private readonly seenNames = new Map<string, string>()
130
+
131
+ constructor(private readonly context: string) {}
132
+
133
+ /** Returns the indented line to emit, or `null` for an exact duplicate. */
134
+ accept(decl: string, indentPrefix: string): string | null {
135
+ if (this.seenStrings.has(decl)) return null
136
+ const name = extractedDeclName(decl)
137
+ if (name != null) {
138
+ if (this.seenNames.has(name)) {
139
+ throw new Error(
140
+ `[ts-procedures-codegen] duplicate identifier '${name}' while emitting ${this.context}. ` +
141
+ `An extracted sub-type collided with another of the same name and could not be renamed. ` +
142
+ `This is a codegen bug — please report it with the offending schema.`,
143
+ )
144
+ }
145
+ this.seenNames.set(name, decl)
146
+ }
147
+ this.seenStrings.add(decl)
148
+ return indent(decl, indentPrefix)
149
+ }
150
+ }
151
+
110
152
  interface NamedType {
111
153
  /** Short name for namespace mode (e.g., 'Params', 'Response'). */
112
154
  shortName: string
@@ -142,7 +184,7 @@ async function formatTypes(
142
184
 
143
185
  if (ctx.namespaceTypes) {
144
186
  const nsLines: string[] = []
145
- const seenDeclarations = new Set<string>()
187
+ const collector = new DeclarationCollector(`namespace ${routePascal}`)
146
188
 
147
189
  // Pre-reserve every name the route will declare itself (each shortName +
148
190
  // any caller-supplied extras). Extracted sub-types whose names land in
@@ -163,12 +205,10 @@ async function formatTypes(
163
205
 
164
206
  const result = renameExtractedTypes(rawResult, taken)
165
207
 
166
- // Collect extracted sub-types (deduplicate across schemas by exact-string)
208
+ // Collect extracted sub-types (dedupe exact dups; throw on real collisions)
167
209
  for (const decl of result.declarations) {
168
- if (!seenDeclarations.has(decl)) {
169
- seenDeclarations.add(decl)
170
- nsLines.push(indent(decl, ' '))
171
- }
210
+ const line = collector.accept(decl, ' ')
211
+ if (line != null) nsLines.push(line)
172
212
  }
173
213
 
174
214
  nsLines.push(` export type ${shortName} = ${result.body}`)
@@ -291,92 +331,379 @@ async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Prom
291
331
  return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
292
332
  }
293
333
 
334
+ /**
335
+ * Formats a group of named types into a nested sub-namespace block (for namespace mode).
336
+ * Returns an array of lines to be inserted into the parent namespace, and a map of
337
+ * shortName → qualified type reference for use in callables.
338
+ *
339
+ * In flat mode, returns declarations like `export type ${prefix}${shortName} = ...`
340
+ * and refs like `${prefix}${shortName}`.
341
+ */
342
+ async function formatSubNamespace(
343
+ routePascal: string,
344
+ nsName: string, // e.g. 'Req' or 'Response'
345
+ types: NamedType[],
346
+ ctx: EmitRouteContext,
347
+ taken: Set<string>,
348
+ ): Promise<{ nsBlock: string | null; refs: Record<string, string> }> {
349
+ const refs: Record<string, string> = {}
350
+ const nsLines: string[] = []
351
+ const collector = new DeclarationCollector(`namespace ${routePascal}.${nsName}`)
352
+
353
+ // Pre-reserve short names to prevent sub-type extraction collision
354
+ for (const t of types) {
355
+ if (t.schema != null) taken.add(t.shortName)
356
+ }
357
+
358
+ for (const { shortName, schema } of types) {
359
+ if (schema == null) continue
360
+
361
+ if (ctx.namespaceTypes) {
362
+ const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
363
+ if (rawResult == null) continue
364
+
365
+ const result = renameExtractedTypes(rawResult, taken)
366
+
367
+ for (const decl of result.declarations) {
368
+ const line = collector.accept(decl, ' ')
369
+ if (line != null) nsLines.push(line)
370
+ }
371
+
372
+ nsLines.push(` export type ${shortName} = ${result.body}`)
373
+ refs[shortName] = `${ctx.scopePascal}.${routePascal}.${nsName}.${shortName}`
374
+ } else {
375
+ const flatName = `${routePascal}${nsName}${shortName}`
376
+ const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
377
+ if (body == null) continue
378
+ refs[shortName] = flatName
379
+ }
380
+ }
381
+
382
+ if (ctx.namespaceTypes) {
383
+ if (nsLines.length === 0) return { nsBlock: null, refs }
384
+ const nsBlock = ` export namespace ${nsName} {\n${nsLines.join('\n')}\n }`
385
+ return { nsBlock, refs }
386
+ }
387
+
388
+ return { nsBlock: null, refs }
389
+ }
390
+
391
+ /**
392
+ * Builds the conditional return type string for an API or http-stream callable.
393
+ *
394
+ * - Both body + headers → `{ body: <Body>; headers: <Headers> }`
395
+ * - Only body → `<Body>`
396
+ * - Only headers → `{ headers: <Headers> }`
397
+ * - Neither → `void`
398
+ */
399
+ function buildApiReturnType(
400
+ bodyRef: string | undefined,
401
+ headersRef: string | undefined,
402
+ ): string {
403
+ if (bodyRef && headersRef) {
404
+ return `{ body: ${bodyRef}; headers: ${headersRef} }`
405
+ }
406
+ if (bodyRef) return bodyRef
407
+ if (headersRef) return `{ headers: ${headersRef} }`
408
+ return 'void'
409
+ }
410
+
294
411
  async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
295
412
  const pascal = toPascalCase(route.name)
296
- const channelKeys = ['pathParams', 'query', 'body', 'headers'] as const
413
+ const req = route.jsonSchema.req ?? {}
414
+ const res = route.jsonSchema.res ?? {}
297
415
 
298
- // Build channel types + structured Params type
299
- const channelTypes: NamedType[] = []
416
+ // Request channels
417
+ const reqChannelKeys = ['pathParams', 'query', 'body', 'headers'] as const
418
+ const reqTypes: NamedType[] = []
300
419
  const presentChannels: string[] = []
301
420
 
302
- for (const channel of channelKeys) {
303
- const channelSchema = route.jsonSchema[channel]
304
- if (channelSchema != null) {
305
- channelTypes.push({ shortName: toPascalCase(channel), schema: channelSchema })
421
+ for (const channel of reqChannelKeys) {
422
+ const schema = req[channel]
423
+ if (schema != null) {
424
+ reqTypes.push({ shortName: toPascalCase(channel), schema })
306
425
  presentChannels.push(channel)
307
426
  }
308
427
  }
309
428
 
310
- // Add response
311
- channelTypes.push({ shortName: 'Response', schema: route.jsonSchema.response })
429
+ // Response slots
430
+ const resTypes: NamedType[] = [
431
+ { shortName: 'Body', schema: res.body },
432
+ { shortName: 'Headers', schema: res.headers },
433
+ ]
312
434
 
313
- // `Params` is injected into the namespace below (after formatTypes), so
314
- // reserve it up-front to keep ajsc-extracted sub-types from shadowing it.
315
- const { declarations, refs } = await formatTypes(
316
- pascal,
317
- channelTypes,
318
- ctx,
319
- new Set(['Params']),
320
- )
435
+ const scopeStr = route.scope ?? 'default'
436
+ const errorUnion = buildErrorUnion(route.errors, ctx)
437
+ const hasErrors = errorUnion !== null
438
+ const errorsRef = ctx.namespaceTypes
439
+ ? `${ctx.scopePascal}.${pascal}.Errors`
440
+ : `${pascal}Errors`
321
441
 
322
- // Compose structured Params type from channels
442
+ const declarations: string[] = []
323
443
  let paramsTypeName = 'unknown'
324
- if (presentChannels.length > 0) {
325
- if (ctx.namespaceTypes) {
326
- const structureFields = presentChannels
327
- .map((ch) => `${ch}: ${toPascalCase(ch)}`)
444
+ let returnTypeName = 'void'
445
+
446
+ // Track reserved names across all sub-namespaces
447
+ const taken = new Set<string>(['Req', 'Response'])
448
+
449
+ if (ctx.namespaceTypes) {
450
+ // Namespace mode: emit nested Req {} and Response {} namespaces inside route namespace.
451
+ // Also emit merged type aliases `export type Req = { ... }` and `export type Response = ...`
452
+ // so they can be used as type arguments to bindCallable (TS requires a TYPE, not a namespace).
453
+ const nsLines: string[] = []
454
+
455
+ const { nsBlock: reqBlock, refs: reqRefs } = await formatSubNamespace(
456
+ pascal, 'Req', reqTypes, ctx, taken
457
+ )
458
+ if (reqBlock) {
459
+ nsLines.push(reqBlock)
460
+ // Merged type alias for Req so it can be used as a generic type arg
461
+ const reqFields = presentChannels
462
+ .map((ch) => `${ch}: Req.${toPascalCase(ch)}`)
328
463
  .join('; ')
329
- // Insert Params type into the namespace block (before closing brace)
330
- const lastIdx = declarations.length - 1
331
- if (lastIdx >= 0) {
332
- const lastDecl = declarations[lastIdx]!
333
- const closingIdx = lastDecl.lastIndexOf(' }')
334
- if (closingIdx !== -1) {
335
- declarations[lastIdx] =
336
- lastDecl.slice(0, closingIdx) +
337
- ` export type Params = { ${structureFields} }\n` +
338
- lastDecl.slice(closingIdx)
339
- }
340
- }
341
- paramsTypeName = `${ctx.scopePascal}.${pascal}.Params`
342
- } else {
464
+ nsLines.push(` export type Req = { ${reqFields} }`)
465
+ }
466
+
467
+ const { nsBlock: resBlock, refs: resRefs } = await formatSubNamespace(
468
+ pascal, 'Response', resTypes, ctx, taken
469
+ )
470
+ if (resBlock) {
471
+ nsLines.push(resBlock)
472
+ // No merged Response type alias needed: we reference Response.Body / Response.Headers
473
+ // directly in the return type string, which are namespace-qualified paths (valid).
474
+ }
475
+
476
+ // Emit Errors type last (injected by injectRouteErrors below)
477
+ // Build the route namespace block
478
+ if (nsLines.length > 0) {
479
+ declarations.push(` export namespace ${pascal} {\n${nsLines.join('\n\n')}\n }`)
480
+ }
481
+
482
+ // Params type: use the merged Req type alias
483
+ if (presentChannels.length > 0) {
484
+ paramsTypeName = `${ctx.scopePascal}.${pascal}.Req`
485
+ }
486
+
487
+ // Return type
488
+ const bodyRef = resRefs['Body']
489
+ const headersRef = resRefs['Headers']
490
+ returnTypeName = buildApiReturnType(bodyRef, headersRef)
491
+ } else {
492
+ // Flat mode: emit individual types prefixed with route + sub-namespace name
493
+ for (const { shortName, schema } of reqTypes) {
494
+ if (schema == null) continue
495
+ const flatName = `${pascal}Req${shortName}`
496
+ const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
497
+ if (body == null) continue
498
+ declarations.push(`export type ${flatName} = ${body}`)
499
+ }
500
+
501
+ // Flat mode: compose structured Req type
502
+ if (presentChannels.length > 0) {
343
503
  const structureFields = presentChannels
344
- .map((ch) => `${ch}: ${refs[toPascalCase(ch)]}`)
504
+ .map((ch) => `${ch}: ${pascal}Req${toPascalCase(ch)}`)
345
505
  .join('; ')
346
- declarations.push(`export type ${pascal}Params = { ${structureFields} }`)
347
- paramsTypeName = `${pascal}Params`
506
+ declarations.push(`export type ${pascal}Req = { ${structureFields} }`)
507
+ paramsTypeName = `${pascal}Req`
348
508
  }
349
- }
350
509
 
351
- const responseTypeName = refs['Response'] ?? 'unknown'
352
- const scopeStr = route.scope ?? 'default'
510
+ // Flat mode: emit response types
511
+ let bodyRef: string | undefined
512
+ let headersRef: string | undefined
353
513
 
354
- const errorUnion = buildErrorUnion(route.errors, ctx)
355
- const hasErrors = errorUnion !== null
356
- const errorsRef = ctx.namespaceTypes
357
- ? `${ctx.scopePascal}.${pascal}.Errors`
358
- : `${pascal}Errors`
514
+ for (const { shortName, schema } of resTypes) {
515
+ if (schema == null) continue
516
+ const flatName = `${pascal}Response${shortName}`
517
+ const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
518
+ if (body == null) continue
519
+ declarations.push(`export type ${flatName} = ${body}`)
520
+ if (shortName === 'Body') bodyRef = flatName
521
+ if (shortName === 'Headers') headersRef = flatName
522
+ }
523
+
524
+ returnTypeName = buildApiReturnType(bodyRef, headersRef)
525
+
526
+ // Flat mode errors
527
+ if (errorUnion) {
528
+ declarations.push(`export type ${pascal}Errors = ${errorUnion}`)
529
+ }
530
+ }
531
+
532
+ const responseHeadersDeclared = res.headers != null
359
533
  const helperCall = hasErrors
360
- ? `client.bindCallableTyped<${paramsTypeName}, ${responseTypeName}, ${errorsRef}>`
361
- : `client.bindCallable<${paramsTypeName}, ${responseTypeName}>`
534
+ ? `client.bindCallableTyped<${paramsTypeName}, ${returnTypeName}, ${errorsRef}>`
535
+ : `client.bindCallable<${paramsTypeName}, ${returnTypeName}>`
362
536
 
363
- // Property key uses route.name verbatim (preserving the prior API emission contract).
364
- const callable = [
365
- ` /** ${route.method.toUpperCase()} ${route.fullPath} */`,
366
- ` ${route.name}: ${helperCall}({`,
537
+ const descriptorLines = [
367
538
  ` name: '${route.name}',`,
368
539
  ` scope: '${scopeStr}',`,
369
540
  ` path: '${route.fullPath}',`,
370
541
  ` method: '${route.method}',`,
371
542
  ` kind: 'api',`,
543
+ ...(responseHeadersDeclared ? [` responseHeadersDeclared: true,`] : []),
544
+ ]
545
+
546
+ const callable = [
547
+ ` /** ${route.method.toUpperCase()} ${route.fullPath} */`,
548
+ ` ${route.name}: ${helperCall}({`,
549
+ ...descriptorLines,
372
550
  ` }),`,
373
551
  ].join('\n')
374
552
 
375
- const hasErrorsInjected = injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
553
+ const hasErrorsInjected = ctx.namespaceTypes
554
+ ? injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
555
+ : errorUnion !== null // flat mode already emitted errors above
376
556
 
377
557
  return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
378
558
  }
379
559
 
560
+ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
561
+ const pascal = toPascalCase(route.name)
562
+ const req = route.jsonSchema.req ?? {}
563
+ const res = route.jsonSchema.res ?? {}
564
+
565
+ // Request channels
566
+ const reqChannelKeys = ['pathParams', 'query', 'body', 'headers'] as const
567
+ const reqTypes: NamedType[] = []
568
+ const presentChannels: string[] = []
569
+
570
+ for (const channel of reqChannelKeys) {
571
+ const schema = req[channel]
572
+ if (schema != null) {
573
+ reqTypes.push({ shortName: toPascalCase(channel), schema })
574
+ presentChannels.push(channel)
575
+ }
576
+ }
577
+
578
+ // Yield + ReturnType
579
+ const yieldSchema = route.jsonSchema.yield
580
+ const returnSchema = route.jsonSchema.returnType
581
+ const resHeadersSchema = res.headers
582
+
583
+ const scopeStr = route.scope ?? 'default'
584
+ const declarations: string[] = []
585
+ let paramsTypeName = 'unknown'
586
+ let yieldTypeName = 'unknown'
587
+ let returnTypeName = 'void'
588
+
589
+ const taken = new Set<string>(['Req', 'Response', 'Yield', 'ReturnType'])
590
+
591
+ if (ctx.namespaceTypes) {
592
+ const nsLines: string[] = []
593
+
594
+ // Req sub-namespace
595
+ const { nsBlock: reqBlock, refs: reqRefs } = await formatSubNamespace(
596
+ pascal, 'Req', reqTypes, ctx, taken
597
+ )
598
+ if (reqBlock) {
599
+ nsLines.push(reqBlock)
600
+ // Merged type alias so Req can be used as a generic type arg (same pattern as emitApiRoute)
601
+ const reqFields = presentChannels
602
+ .map((ch) => `${ch}: Req.${toPascalCase(ch)}`)
603
+ .join('; ')
604
+ nsLines.push(` export type Req = { ${reqFields} }`)
605
+ }
606
+
607
+ // Response sub-namespace (headers only for http-stream)
608
+ const resTypes: NamedType[] = [{ shortName: 'Headers', schema: resHeadersSchema }]
609
+ const { nsBlock: resBlock } = await formatSubNamespace(
610
+ pascal, 'Response', resTypes, ctx, taken
611
+ )
612
+ if (resBlock) nsLines.push(resBlock)
613
+
614
+ // Yield and ReturnType directly in the route namespace
615
+ const directTypes: NamedType[] = [
616
+ { shortName: 'Yield', schema: yieldSchema },
617
+ { shortName: 'ReturnType', schema: returnSchema },
618
+ ]
619
+ const collector = new DeclarationCollector(`namespace ${pascal}`)
620
+ for (const { shortName, schema } of directTypes) {
621
+ if (schema == null) continue
622
+ const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
623
+ if (rawResult == null) continue
624
+ const result = renameExtractedTypes(rawResult, taken)
625
+ for (const decl of result.declarations) {
626
+ const line = collector.accept(decl, ' ')
627
+ if (line != null) nsLines.push(line)
628
+ }
629
+ nsLines.push(` export type ${shortName} = ${result.body}`)
630
+ }
631
+
632
+ if (nsLines.length > 0) {
633
+ declarations.push(` export namespace ${pascal} {\n${nsLines.join('\n')}\n }`)
634
+ }
635
+
636
+ if (presentChannels.length > 0) {
637
+ paramsTypeName = `${ctx.scopePascal}.${pascal}.Req`
638
+ }
639
+
640
+ if (yieldSchema != null) yieldTypeName = `${ctx.scopePascal}.${pascal}.Yield`
641
+ if (returnSchema != null) returnTypeName = `${ctx.scopePascal}.${pascal}.ReturnType`
642
+ } else {
643
+ // Flat mode
644
+ for (const { shortName, schema } of reqTypes) {
645
+ if (schema == null) continue
646
+ const flatName = `${pascal}Req${shortName}`
647
+ const body = await jsonSchemaToTypeBody(schema, ctx.ajsc)
648
+ if (body == null) continue
649
+ declarations.push(`export type ${flatName} = ${body}`)
650
+ }
651
+
652
+ if (presentChannels.length > 0) {
653
+ const structureFields = presentChannels
654
+ .map((ch) => `${ch}: ${pascal}Req${toPascalCase(ch)}`)
655
+ .join('; ')
656
+ declarations.push(`export type ${pascal}Req = { ${structureFields} }`)
657
+ paramsTypeName = `${pascal}Req`
658
+ }
659
+
660
+ if (resHeadersSchema != null) {
661
+ const body = await jsonSchemaToTypeBody(resHeadersSchema, ctx.ajsc)
662
+ if (body != null) declarations.push(`export type ${pascal}ResponseHeaders = ${body}`)
663
+ }
664
+
665
+ if (yieldSchema != null) {
666
+ const body = await jsonSchemaToTypeBody(yieldSchema, ctx.ajsc)
667
+ if (body != null) {
668
+ declarations.push(`export type ${pascal}Yield = ${body}`)
669
+ yieldTypeName = `${pascal}Yield`
670
+ }
671
+ }
672
+
673
+ if (returnSchema != null) {
674
+ const body = await jsonSchemaToTypeBody(returnSchema, ctx.ajsc)
675
+ if (body != null) {
676
+ declarations.push(`export type ${pascal}ReturnType = ${body}`)
677
+ returnTypeName = `${pascal}ReturnType`
678
+ }
679
+ }
680
+ }
681
+
682
+ const callable = [
683
+ ` /** ${route.method.toUpperCase()} ${route.fullPath} */`,
684
+ ` ${route.name}(req: ${paramsTypeName}, options?: ProcedureCallOptions): TypedStream<${yieldTypeName}, ${returnTypeName}> {`,
685
+ ` return client.stream<${yieldTypeName}, ${returnTypeName}>({`,
686
+ ` name: '${route.name}',`,
687
+ ` scope: '${scopeStr}',`,
688
+ ` path: '${route.fullPath}',`,
689
+ ` method: '${route.method}',`,
690
+ ` kind: 'http-stream',`,
691
+ ` streamMode: '${route.streamMode}',`,
692
+ ` params: req,`,
693
+ ` }, options)`,
694
+ ` },`,
695
+ ].join('\n')
696
+
697
+ const hasErrors = injectRouteErrors(
698
+ declarations,
699
+ pascal,
700
+ buildErrorUnion(route.errors, ctx),
701
+ ctx.namespaceTypes
702
+ )
703
+
704
+ return { typeDeclarations: declarations, callable, hasStream: true, hasErrors }
705
+ }
706
+
380
707
  async function emitStreamRoute(route: StreamHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
381
708
  const pascal = versionedPascal(route.name, route.version)
382
709
 
@@ -470,6 +797,8 @@ export async function emitScopeFile(
470
797
  chunks = await emitApiRoute(route as APIHttpRouteDoc, ctx)
471
798
  } else if (kind === 'stream') {
472
799
  chunks = await emitStreamRoute(route as StreamHttpRouteDoc, ctx)
800
+ } else if (kind === 'http-stream') {
801
+ chunks = await emitHttpStreamRoute(route as HttpStreamRouteDoc, ctx)
473
802
  } else {
474
803
  throw new Error(`Unknown route kind "${kind}"`)
475
804
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import { jsonSchemaToTypeString, jsonSchemaToTypeBody, jsonSchemaToExtractedTypes } from './emit-types.js'
2
+ import { jsonSchemaToTypeString, jsonSchemaToTypeBody, jsonSchemaToExtractedTypes, renameExtractedTypes, extractedDeclName } from './emit-types.js'
3
3
 
4
4
  // ---------------------------------------------------------------------------
5
5
  // Tests
@@ -212,6 +212,78 @@ describe('jsonSchemaToExtractedTypes', () => {
212
212
  )
213
213
  expect(hasItemsDecl).toBe(true)
214
214
  })
215
+
216
+ // Root-cause guard: ajsc glues SIBLING extracted declarations with a single
217
+ // "\n" inside one block. If they aren't split into separate elements, the
218
+ // downstream rename/dedup logic only sees the first one -> duplicate
219
+ // identifiers (TS2300). Each extracted sub-type must occupy its own element.
220
+ it('returns one declaration per element when a schema yields multiple sub-types', async () => {
221
+ const schema = {
222
+ type: 'object',
223
+ required: ['contact'],
224
+ properties: {
225
+ contact: {
226
+ type: 'object',
227
+ required: ['name', 'address'],
228
+ properties: {
229
+ name: { type: 'string' },
230
+ address: {
231
+ type: 'object',
232
+ required: ['city'],
233
+ properties: { city: { type: 'string' } },
234
+ },
235
+ },
236
+ },
237
+ },
238
+ }
239
+ const result = await jsonSchemaToExtractedTypes(schema)
240
+ expect(result).not.toBeUndefined()
241
+ // Two sub-types are extracted: Contact and Address.
242
+ const names = result!.declarations.map((d) => extractedDeclName(d))
243
+ expect(names).toContain('Contact')
244
+ expect(names).toContain('Address')
245
+ // Each declaration element holds exactly one `export type|enum|interface`.
246
+ for (const decl of result!.declarations) {
247
+ const count = decl.match(/export\s+(?:type|enum|interface)\s+/g)?.length ?? 0
248
+ expect(count).toBe(1)
249
+ }
250
+ })
251
+ })
252
+
253
+ describe('renameExtractedTypes', () => {
254
+ it('renames a colliding declaration and patches its references in the body', () => {
255
+ const result = {
256
+ declarations: ['export type Address = { city: string; }'],
257
+ body: '{ address: Address; }',
258
+ }
259
+ const out = renameExtractedTypes(result, new Set(['Address']))
260
+ expect(out.declarations[0]).toContain('export type AddressInner =')
261
+ expect(out.body).toBe('{ address: AddressInner; }')
262
+ })
263
+
264
+ // Latent correctness guard: extracted sub-types reference each other. When a
265
+ // referenced sub-type is renamed, the reference inside its SIBLING
266
+ // declaration must be patched too — otherwise the renamed type silently
267
+ // resolves to a same-named sub-type from a different schema.
268
+ it('patches cross-references between sibling declarations on rename', () => {
269
+ const result = {
270
+ declarations: [
271
+ 'export type Contact = { address: Address; }',
272
+ 'export type Address = { city: string; }',
273
+ ],
274
+ body: '{ contact: Contact; }',
275
+ }
276
+ // Both names already taken (as if extracted from a second schema sharing them).
277
+ const out = renameExtractedTypes(result, new Set(['Contact', 'Address']))
278
+ // Both declarations renamed.
279
+ expect(out.declarations[0]).toContain('export type ContactInner =')
280
+ expect(out.declarations[1]).toContain('export type AddressInner =')
281
+ // The reference inside ContactInner now points at AddressInner, not the
282
+ // stale Address (which would belong to the other schema).
283
+ expect(out.declarations[0]).toContain('address: AddressInner')
284
+ expect(out.declarations[0]).not.toMatch(/address: Address\b/)
285
+ expect(out.body).toBe('{ contact: ContactInner; }')
286
+ })
215
287
  })
216
288
 
217
289
  describe('jsonSchemaToTypeString (prefix stripping)', () => {