ts-procedures 8.3.0 → 8.5.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 (126) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
  2. package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
  3. package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
  4. package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
  5. package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
  6. package/build/client/call.js +1 -1
  7. package/build/client/call.js.map +1 -1
  8. package/build/client/index.d.ts +1 -1
  9. package/build/client/index.js +23 -1
  10. package/build/client/index.js.map +1 -1
  11. package/build/client/index.test.js +87 -0
  12. package/build/client/index.test.js.map +1 -1
  13. package/build/client/resolve-options.d.ts +5 -4
  14. package/build/client/resolve-options.js +18 -7
  15. package/build/client/resolve-options.js.map +1 -1
  16. package/build/client/resolve-options.test.js +53 -24
  17. package/build/client/resolve-options.test.js.map +1 -1
  18. package/build/client/stream.js +1 -1
  19. package/build/client/stream.js.map +1 -1
  20. package/build/client/types.d.ts +31 -3
  21. package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
  22. package/build/codegen/__fixtures__/make-envelope.js +38 -0
  23. package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
  24. package/build/codegen/bin/cli.d.ts +15 -0
  25. package/build/codegen/bin/cli.js +46 -21
  26. package/build/codegen/bin/cli.js.map +1 -1
  27. package/build/codegen/bin/cli.test.js +54 -1
  28. package/build/codegen/bin/cli.test.js.map +1 -1
  29. package/build/codegen/bin/flag-specs.d.ts +10 -0
  30. package/build/codegen/bin/flag-specs.js +62 -0
  31. package/build/codegen/bin/flag-specs.js.map +1 -0
  32. package/build/codegen/bin/flag-specs.test.d.ts +1 -0
  33. package/build/codegen/bin/flag-specs.test.js +35 -0
  34. package/build/codegen/bin/flag-specs.test.js.map +1 -0
  35. package/build/codegen/collect-models.d.ts +48 -0
  36. package/build/codegen/collect-models.js +84 -0
  37. package/build/codegen/collect-models.js.map +1 -0
  38. package/build/codegen/collect-models.test.d.ts +1 -0
  39. package/build/codegen/collect-models.test.js +59 -0
  40. package/build/codegen/collect-models.test.js.map +1 -0
  41. package/build/codegen/emit-client-runtime.js +1 -0
  42. package/build/codegen/emit-client-runtime.js.map +1 -1
  43. package/build/codegen/emit-models.d.ts +26 -0
  44. package/build/codegen/emit-models.js +53 -0
  45. package/build/codegen/emit-models.js.map +1 -0
  46. package/build/codegen/emit-models.test.d.ts +1 -0
  47. package/build/codegen/emit-models.test.js +42 -0
  48. package/build/codegen/emit-models.test.js.map +1 -0
  49. package/build/codegen/emit-scope.d.ts +10 -0
  50. package/build/codegen/emit-scope.js +119 -34
  51. package/build/codegen/emit-scope.js.map +1 -1
  52. package/build/codegen/emit-types.d.ts +26 -1
  53. package/build/codegen/emit-types.js +27 -5
  54. package/build/codegen/emit-types.js.map +1 -1
  55. package/build/codegen/index.d.ts +15 -0
  56. package/build/codegen/index.js +5 -0
  57. package/build/codegen/index.js.map +1 -1
  58. package/build/codegen/model-refs.d.ts +27 -0
  59. package/build/codegen/model-refs.js +49 -0
  60. package/build/codegen/model-refs.js.map +1 -0
  61. package/build/codegen/model-refs.test.d.ts +1 -0
  62. package/build/codegen/model-refs.test.js +33 -0
  63. package/build/codegen/model-refs.test.js.map +1 -0
  64. package/build/codegen/pipeline.d.ts +7 -0
  65. package/build/codegen/pipeline.js +6 -1
  66. package/build/codegen/pipeline.js.map +1 -1
  67. package/build/codegen/schema-walk.d.ts +13 -0
  68. package/build/codegen/schema-walk.js +26 -0
  69. package/build/codegen/schema-walk.js.map +1 -0
  70. package/build/codegen/schema-walk.test.d.ts +1 -0
  71. package/build/codegen/schema-walk.test.js +35 -0
  72. package/build/codegen/schema-walk.test.js.map +1 -0
  73. package/build/codegen/targets/_shared/target-run.d.ts +15 -0
  74. package/build/codegen/targets/ts/run.js +37 -1
  75. package/build/codegen/targets/ts/run.js.map +1 -1
  76. package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
  77. package/build/codegen/targets/ts/shared-models.test.js +354 -0
  78. package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
  79. package/build/doc-envelope.d.ts +13 -0
  80. package/build/doc-envelope.js +23 -0
  81. package/build/doc-envelope.js.map +1 -0
  82. package/build/doc-envelope.test.d.ts +1 -0
  83. package/build/doc-envelope.test.js +31 -0
  84. package/build/doc-envelope.test.js.map +1 -0
  85. package/build/exports.d.ts +2 -0
  86. package/build/exports.js +1 -0
  87. package/build/exports.js.map +1 -1
  88. package/docs/client-and-codegen.md +163 -0
  89. package/docs/handoffs/ajsc-named-type-collision.md +134 -0
  90. package/docs/handoffs/ajsc-named-type-support.md +181 -0
  91. package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
  92. package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
  93. package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
  94. package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
  95. package/package.json +2 -2
  96. package/src/client/call.ts +1 -1
  97. package/src/client/index.test.ts +98 -0
  98. package/src/client/index.ts +32 -1
  99. package/src/client/resolve-options.test.ts +73 -26
  100. package/src/client/resolve-options.ts +23 -9
  101. package/src/client/stream.ts +1 -1
  102. package/src/client/types.ts +34 -3
  103. package/src/codegen/__fixtures__/make-envelope.ts +89 -0
  104. package/src/codegen/bin/cli.test.ts +65 -1
  105. package/src/codegen/bin/cli.ts +51 -22
  106. package/src/codegen/bin/flag-specs.test.ts +38 -0
  107. package/src/codegen/bin/flag-specs.ts +71 -0
  108. package/src/codegen/collect-models.test.ts +68 -0
  109. package/src/codegen/collect-models.ts +125 -0
  110. package/src/codegen/emit-client-runtime.ts +1 -0
  111. package/src/codegen/emit-models.test.ts +48 -0
  112. package/src/codegen/emit-models.ts +63 -0
  113. package/src/codegen/emit-scope.ts +145 -33
  114. package/src/codegen/emit-types.ts +48 -7
  115. package/src/codegen/index.ts +20 -0
  116. package/src/codegen/model-refs.test.ts +37 -0
  117. package/src/codegen/model-refs.ts +57 -0
  118. package/src/codegen/pipeline.ts +13 -1
  119. package/src/codegen/schema-walk.test.ts +37 -0
  120. package/src/codegen/schema-walk.ts +23 -0
  121. package/src/codegen/targets/_shared/target-run.ts +15 -0
  122. package/src/codegen/targets/ts/run.ts +50 -0
  123. package/src/codegen/targets/ts/shared-models.test.ts +391 -0
  124. package/src/doc-envelope.test.ts +35 -0
  125. package/src/doc-envelope.ts +30 -0
  126. package/src/exports.ts +2 -0
@@ -72,6 +72,32 @@ const api = createClient({
72
72
  })
73
73
  ```
74
74
 
75
+ ## Offline codegen (no running server)
76
+
77
+ You don't need a live HTTP server to generate the client. Serialize the DocEnvelope to disk with `writeDocEnvelope`, then point codegen at the file with `--file`.
78
+
79
+ **Step 1 — Emit the envelope to a file:**
80
+
81
+ ```typescript
82
+ // scripts/emit-docs.ts
83
+ import { writeDocEnvelope } from 'ts-procedures'
84
+ import { buildApp } from '../src/app.js' // builds your HonoAppBuilder / DocRegistry
85
+
86
+ const builder = buildApp()
87
+ await writeDocEnvelope(builder, 'docs.json')
88
+ ```
89
+
90
+ `writeDocEnvelope` accepts a built `HonoAppBuilder` (via `toDocEnvelope()`), a `DocRegistry` (via `toJSON()`), or a plain `DocEnvelope` object — no running HTTP server required. Parent directories are created as needed and the envelope is written as pretty JSON.
91
+
92
+ **Step 2 — Run codegen against the file:**
93
+
94
+ ```bash
95
+ tsx scripts/emit-docs.ts
96
+ npx ts-procedures-codegen --file docs.json --out src/generated --service-name Api
97
+ ```
98
+
99
+ This is handy for CI pipelines and monorepos where the client is generated as a build step without standing up the server.
100
+
75
101
  ## Generated File Structure
76
102
 
77
103
  Running the codegen command produces one file per scope, plus shared types, client runtime, error types, and a barrel export:
@@ -108,6 +134,9 @@ generated/
108
134
  | `--uncountable-words <list>` | Comma-separated words to skip singularization (ignored with `--no-namespace-types`) | Off |
109
135
  | `--service-name <name>` | Names the service namespace and binding factory (e.g. `Auth` → `export namespace Auth` + `createAuthBindings`). Also prefixes `${Name}Errors` and `${Name}ProcedureErrorUnion` in `_errors.ts`. | `Api` |
110
136
  | `--clean-out-dir` / `--no-clean-out-dir` | Prune orphaned generated files from `--out <dir>` before writing — files carrying the `ts-procedures-codegen` signature that this run no longer emits (e.g. a deleted scope). Hand-written files (no signature) are never removed, and subdirectories are left untouched. Skipped under `--dry-run`. Pass `--no-clean-out-dir` to opt out. | **On** |
137
+ | `--share-models` / `--no-share-models` | Collect `$id`-bearing subschemas into a shared `_models.ts` hub; scopes import from there instead of inlining types. | **On** |
138
+ | `--shared-models-module <module>` | Convention form: re-export every `$id` model (with no explicit `sharedTypesImport` entry) from this single module. E.g. `--shared-models-module @app/schemas`. | Off |
139
+ | `--strict-shared-models` | Fail the build if any `$id` model would be generated locally as a structural twin (i.e., not covered by `sharedTypesImport` or `sharedModelsModule`). CI guard. | Off |
111
140
 
112
141
  > **Note:** ajsc formatting options (`--enum-style`, `--depluralize`, `--array-item-naming`, `--uncountable-words`) only take effect in namespace mode (the default). With `--no-namespace-types`, all types are inlined and these options have no effect.
113
142
  >
@@ -199,6 +228,81 @@ const client = createClient({
199
228
  })
200
229
  ```
201
230
 
231
+ ### Authentication (rotating tokens)
232
+
233
+ A bearer token usually changes over the lifetime of a client — it expires and gets refreshed. The catch: a **static** `headers` record is captured **once**, when you create the client (or when you build the per-call options object). Whatever token value was in scope at that moment is frozen into the request forever:
234
+
235
+ ```typescript
236
+ // ⚠️ Goes stale: `session.token` is read once, at client construction.
237
+ const client = createClient({
238
+ adapter,
239
+ scopes: createApiBindings,
240
+ defaults: {
241
+ headers: { Authorization: `Bearer ${session.token}` },
242
+ },
243
+ })
244
+ ```
245
+
246
+ There are two seams for re-evaluating the value on every request.
247
+
248
+ **Function-valued `headers` (recommended).** Instead of a record, pass a function. It's invoked (and awaited, if it returns a promise) on every call, so the current token is always read fresh:
249
+
250
+ ```typescript
251
+ const client = createClient({
252
+ adapter,
253
+ scopes: createApiBindings,
254
+ defaults: {
255
+ // Re-evaluated per request — never goes stale.
256
+ headers: () => ({ Authorization: `Bearer ${session.token}` }),
257
+ },
258
+ })
259
+
260
+ // The function may be async — useful when the token is loaded on demand:
261
+ const client = createClient({
262
+ adapter,
263
+ scopes: createApiBindings,
264
+ defaults: {
265
+ headers: async () => ({ Authorization: `Bearer ${await getAccessToken()}` }),
266
+ },
267
+ })
268
+ ```
269
+
270
+ The function form works everywhere a `headers` record does — client `defaults.headers` and per-call `options.headers` — and the same precedence applies: per-call headers (function or record) win over default headers, and route-declared headers from `schema.input.headers` win over both. A per-call function override:
271
+
272
+ ```typescript
273
+ await client.users.GetUser(
274
+ { pathParams: { id: '123' } },
275
+ { headers: () => ({ Authorization: `Bearer ${oneOffToken}` }) },
276
+ )
277
+ ```
278
+
279
+ **The `auth` shorthand (most concise).** For the common case — a single rotating bearer token — pass `auth: () => session.token` directly on the client config. It's sugar over function-valued `headers`: re-evaluated per request, wired to `Authorization: Bearer <token>` internally, and a `null`/`undefined` return omits the header (handy while a session is still loading). It composes with `defaults.headers` (both are applied) and remains overridable by per-call `headers` and `onBeforeRequest`:
280
+
281
+ ```typescript
282
+ const client = createClient({
283
+ adapter,
284
+ scopes: createApiBindings,
285
+ auth: () => session.token, // may be async; null/undefined → no Authorization header
286
+ })
287
+ ```
288
+
289
+ **The `onBeforeRequest` hook (alternative).** If you need full access to the outgoing request — not just the header values — mutate it from the hook. The hook runs last and has the final say (see [Precedence](#precedence)):
290
+
291
+ ```typescript
292
+ const client = createClient({
293
+ adapter,
294
+ scopes: createApiBindings,
295
+ hooks: {
296
+ onBeforeRequest(ctx) {
297
+ ctx.request.headers = { ...ctx.request.headers, Authorization: `Bearer ${session.token}` }
298
+ return ctx
299
+ },
300
+ },
301
+ })
302
+ ```
303
+
304
+ > **Warning:** A token placed in a **static** `headers` record (`headers: { Authorization: ... }`) type-checks fine and silently goes stale — the request keeps sending whatever token was current at construction time. For any value that changes between calls, use the function form or `onBeforeRequest`.
305
+
202
306
  ### Typed Per-Request Metadata
203
307
 
204
308
  Every `AdapterRequest` carries an optional `meta` field typed via the `RequestMeta` interface. `RequestMeta` is declared empty by design — augment it in your own project via TypeScript declaration merging and your fields become typed end-to-end: in per-call options, in hook contexts, and inside your adapter.
@@ -426,6 +530,65 @@ await generateClient({
426
530
  })
427
531
  ```
428
532
 
533
+ ## Shared Model Types (`_models.ts`)
534
+
535
+ When `shareModels` is on (the default), the codegen pre-pass collects every subschema with a `$id` field, deduplicates them, and emits a single `_models.ts` hub. Every scope that references one of those types imports from `./_models` instead of inlining it — so the type is defined once and shared across scopes.
536
+
537
+ ### `sharedTypesImport` — per-`$id` override map
538
+
539
+ For each `$id` you'd rather import from your own package instead of generating locally, add an entry to `sharedTypesImport` in the config file:
540
+
541
+ ```jsonc
542
+ // ts-procedures-codegen.config.json
543
+ {
544
+ "sharedTypesImport": {
545
+ "https://example.com/schemas/Message": { "module": "@app/schemas", "name": "Message" }
546
+ }
547
+ }
548
+ ```
549
+
550
+ `_models.ts` then re-exports that type from `@app/schemas` instead of generating it — scopes still import from `./_models` (single hub, no scattered imports).
551
+
552
+ ### `sharedModelsModule` — one-value convention
553
+
554
+ When ALL (or most) of your `$id`-bearing models live in a single shared package, use `sharedModelsModule` instead of listing each one in the per-`$id` map:
555
+
556
+ ```jsonc
557
+ // ts-procedures-codegen.config.json — collapse the per-$id map to one module
558
+ { "sharedModelsModule": "@app/schemas" }
559
+ ```
560
+
561
+ ```bash
562
+ npx ts-procedures-codegen --file envelope.json --out gen --shared-models-module @app/schemas
563
+ ```
564
+
565
+ Every `$id` model that has no explicit `sharedTypesImport` entry is re-exported from this single module under its derived name (the convention: `$id`/`title` === the exported type name). The generated scope file then imports `Api.Users.GetUser.Response.Body` from `./_models`, which re-exports it from `@app/schemas` — one symbol, not a structural twin.
566
+
567
+ Precedence in `resolveModelImports`: explicit `sharedTypesImport[$id]` → `sharedModelsModule` → generate locally. Use the per-`$id` map only for overrides (rename, a different package per type, multi-consumer setups).
568
+
569
+ ### Shared-models diagnostics
570
+
571
+ On the CLI, every codegen run that has `$id`-bearing models prints a neutral one-line summary:
572
+
573
+ ```
574
+ [ts-procedures-codegen] Shared models: 5 total — 4 re-exported, 1 generated locally.
575
+ ```
576
+
577
+ This makes a silently-generated structural twin visible — if a model you expected to re-export appears in "generated locally", check that its `$id` is covered by `sharedTypesImport` or `sharedModelsModule`.
578
+
579
+ When calling `generateClient` programmatically the run is silent by default (no stray stdout in your build scripts or tests); pass `logger: console.log` to opt into the same summary.
580
+
581
+ **CI guard — `--strict-shared-models`:** pass this flag (or set `strictSharedModels: true` in the config) to turn the silent fallback into a hard error. Codegen will list every `$id` that would be generated as a local structural twin and exit non-zero — a reliable guard against the single-source-of-truth silently degrading:
582
+
583
+ ```bash
584
+ # CI: fail the build if any domain entity is generated as a structural twin
585
+ npx ts-procedures-codegen --file envelope.json --out gen --strict-shared-models
586
+ ```
587
+
588
+ `strictSharedModels` is off by default so existing setups are unaffected. Enable it once you've confirmed all expected `$id` models are covered.
589
+
590
+ > **Note:** Source-tree scanning (e.g. a `--shared-types-from <dir>` flag) was deliberately not implemented. Codegen is pure-envelope: `--url` and offline `--file` codegen never depend on a local source tree, so it works identically in CI and on a developer's machine.
591
+
429
592
  ## Self-Contained Mode (Default)
430
593
 
431
594
  By default, the generated output includes two additional files in the output directory:
@@ -0,0 +1,134 @@
1
+ # Handoff: x-named-type reference should win over a same-named structural extraction
2
+
3
+ > **STATUS: OPEN REQUEST — not yet shipped in ajsc.** ts-procedures currently DETECTS this collision and throws a route-qualified error, rejecting an otherwise-valid schema. We'd retire that detect-and-error once ajsc disambiguates per the proposal below.
4
+
5
+ **To:** ajsc maintainer
6
+ **From:** ts-procedures codegen (consumer of ajsc as the JSON-Schema → TS/Kotlin/Swift emitter)
7
+ **Date:** 2026-06-06
8
+ **ajsc version inspected:** 7.3.0
9
+ **Priority:** medium — removes a DX wart in ts-procedures (a valid schema is rejected); no current miscompilation, because we detect-and-error rather than emit wrong code
10
+
11
+ ---
12
+
13
+ ## TL;DR
14
+
15
+ `x-named-type` (shipped in 7.3.0, thank you) lets ts-procedures reference a shared model `Message` instead of inlining it. But when a route schema *also* has a property whose ajsc-derived extraction name equals that model name, ajsc **silently merges** the two: the external reference resolves to the unrelated structural sub-type. Your README already documents this caveat and the detection signal (`extractedTypeNames` ∩ `referencedNamedTypes`).
16
+
17
+ The ask: make an `x-named-type` reference **take precedence** over a would-be structural extraction of the same name — rename the *extraction*, keep the *reference* verbatim. The reference is an explicit author intent ("this is the external `Message`"); the extraction name is an ajsc-derived convenience. Intent should win.
18
+
19
+ Additive and opt-in in effect (only changes output for schemas that currently collide, which today produce silently-wrong code). Nothing changes when no collision exists.
20
+
21
+ ---
22
+
23
+ ## Why this happens
24
+
25
+ ts-procedures hoists every `$id`-bearing subschema into a shared `_models.ts` and rewrites each route schema's occurrence into `{ "x-named-type": "<Name>" }` so routes *reference* the model rather than inline it (the integration contract from the prior `x-named-type` handoff, now live).
26
+
27
+ Separately, with `inlineTypes: false`, ajsc extracts inline nested objects into sub-types **named after the property** (PascalCased). So a route schema can independently produce an extracted sub-type whose name happens to equal a model's title.
28
+
29
+ When those two names coincide, ajsc's `$ref`/extraction merge collapses them. Because the merge happens **inside ajsc, before codegen sees any output**, a post-hoc rename downstream can't fix it — renaming the emitted text would rewrite the model reference too, yielding a silently-wrong type. The merge is lossy at the source.
30
+
31
+ ## What ajsc 7.3.0 does today (the blocker)
32
+
33
+ A route property named `message` (an inline object) PascalCases to an extracted sub-type `Message`. A sibling property `latest` references the external model via `{ "x-named-type": "Message" }`. The reference and the extraction share the name `Message`, and ajsc merges them — `latest` resolves to the structural `{ unread }`, **not** the external `Message`.
34
+
35
+ ```jsonc
36
+ // input route schema (inlineTypes: false)
37
+ { "type": "object", "properties": {
38
+ "latest": { "x-named-type": "Message" }, // external ref → shared _models.ts Message
39
+ "message": { "type": "object", "title": "Message", // inline → ajsc extracts a sub-type "Message"
40
+ "properties": { "unread": { "type": "boolean" } } } } }
41
+ ```
42
+
43
+ ```ts
44
+ // ajsc 7.3.0 output — the extracted structural type wins; the external reference is lost
45
+ export type Message = { unread?: boolean }; // structural extraction (from `message`)
46
+ export type Root = { latest?: Message; message?: Message };
47
+ // ^^^^^^^ WRONG — `latest` now points at { unread } instead of the
48
+ // real shared Message model. The x-named-type intent was silently merged away.
49
+ // converter.referencedNamedTypes === ['Message'] AND converter.extractedTypeNames === ['Message', …]
50
+ // → both lists contain 'Message': the documented collision signal.
51
+ ```
52
+
53
+ ```ts
54
+ // desired output — the reference wins, the extraction is renamed
55
+ export type MessageRef = { unread?: boolean }; // structural extraction, disambiguated
56
+ export type Root = { latest?: Message; message?: MessageRef };
57
+ // ^^^^^^^ correct — external Message preserved (imported from _models)
58
+ // ^^^^^^^^^^ structural sub-type disambiguated
59
+ // converter.extractedTypeNames === ['MessageRef', …] // the rename is reported back
60
+ // converter.referencedNamedTypes === ['Message'] // reference untouched
61
+ ```
62
+
63
+ The reference is what the author explicitly asked for; the extraction name is incidental. The reference should be the one preserved verbatim.
64
+
65
+ ---
66
+
67
+ ## Requested feature
68
+
69
+ When a name referenced via `x-named-type` would collide with a name ajsc is about to assign to an *extracted* structural sub-type, **the reference wins**: keep the referenced name verbatim and disambiguate the extraction. Pick whichever of the following best fits ajsc's internals — listed in our order of preference:
70
+
71
+ ### Option (a) — x-named-type references win (recommended)
72
+
73
+ When a referenced name collides with a would-be extraction, **rename the extraction** (append a suffix — `Ref`, `Inner`, or a numeric tail like ajsc already uses elsewhere) and keep the reference verbatim. Surface the *final* extraction name in `extractedTypeNames` so the caller sees the rename. The referenced name in `referencedNamedTypes` is unchanged.
74
+
75
+ This is the most "just works" outcome: the schema author gets a correct external reference and a (renamed) structural type, with zero new API surface. It mirrors ajsc's existing sub-type-naming disambiguation, just seeded by the set of referenced names.
76
+
77
+ ### Option (b) — a `reservedExtractionNames` converter option
78
+
79
+ Add a converter option — `reservedExtractionNames?: Set<string>` (or `reservedNames`) — so the caller can hand ajsc the set of model names up front and guarantee extractions avoid them (renaming on conflict). This puts the caller in control and is explicit, at the cost of one new option. ts-procedures would pass the full set of `_models.ts` names. Functionally equivalent to (a) for our case; (a) is preferable because it needs no caller wiring and protects every consumer by default.
80
+
81
+ ### Option (c) — explicit no-op acknowledgement
82
+
83
+ If you prefer to keep disambiguation as the caller's responsibility, a short note in the README confirming that — and that the documented `extractedTypeNames` ∩ `referencedNamedTypes` signal is the intended detection mechanism — lets us keep our detect-and-error in good conscience. Least preferred: it leaves a valid schema rejectable downstream.
84
+
85
+ **Recommendation: (a).** It removes the failure mode for every consumer with no new API and matches the principle that an explicit reference outranks a derived extraction name.
86
+
87
+ ---
88
+
89
+ ## Current downstream interim (the DX wart)
90
+
91
+ ts-procedures DETECTS the collision and throws rather than emit wrong code. The check (`assertNoModelNameCollision` in `src/codegen/emit-scope.ts`) is the **set intersection** of the converter's two reported lists — ajsc's documented signal:
92
+
93
+ ```
94
+ collision = referencedNamedTypes ∩ extractedTypeNames
95
+ ```
96
+
97
+ A non-empty intersection means a referenced model name also appears as an ajsc-extracted declaration — i.e. the silent merge happened. We throw a clear, route-qualified error telling the author to rename the colliding property or change the model's `$id`/`title`. This is correct but unfortunate: the schema is *valid*, and the author is forced to rename something to satisfy a code-generator limitation. We'd retire this the moment ajsc disambiguates.
98
+
99
+ ---
100
+
101
+ ## How ts-procedures will consume it (integration contract)
102
+
103
+ Once ajsc disambiguates (option (a) or (b)):
104
+
105
+ 1. We **drop the detect-and-error** in `emit-scope.ts` — or downgrade it to a defensive `assert` (a non-empty intersection should then be impossible, so a remaining one would indicate an ajsc regression rather than a user error).
106
+ 2. The colliding schema **just works**: `latest: Message` resolves to the shared `_models.ts` model; the structural sibling emits under its disambiguated name (e.g. `MessageRef`), referenced correctly from the route type.
107
+ 3. We continue to read `referencedNamedTypes` for imports (unchanged) and `extractedTypeNames` for the route's local type set — which, under option (a), now carries the renamed extraction automatically.
108
+
109
+ The contract we depend on: **a referenced `x-named-type` name is never overwritten by an extraction — the extraction yields**, and the final extraction name is reported in `extractedTypeNames`.
110
+
111
+ ---
112
+
113
+ ## Tests worth adding
114
+
115
+ 1. `x-named-type: "Message"` sibling to an inline object property `message` (PascalCases to `Message`) ⇒ reference resolves to external `Message`; extraction renamed (e.g. `MessageRef`); `extractedTypeNames` carries the renamed name; `referencedNamedTypes === ['Message']`; the two lists no longer intersect.
116
+ 2. Same model referenced at N sites alongside one colliding extraction ⇒ all references stay verbatim; only the extraction is renamed.
117
+ 3. Multiple distinct collisions in one schema ⇒ each extraction independently disambiguated; references all preserved.
118
+ 4. No collision (referenced name ≠ any extraction name) ⇒ byte-identical to current 7.3.0 output (regression guard).
119
+ 5. Option (b) only: `reservedExtractionNames` containing a name that *would not* otherwise collide ⇒ extraction proactively avoids it; references unaffected.
120
+
121
+ ---
122
+
123
+ ## Versioning
124
+
125
+ Additive in effect — it only changes output for schemas that **currently miscompile** (a silent merge). For every non-colliding schema the output is byte-identical. A **minor** bump (e.g. 7.4.0). ts-procedures will gate the removal of its detect-and-error on the ajsc version that introduces the precedence rule, keeping the guard as a fallback for one release if you'd prefer a soft cutover.
126
+
127
+ ---
128
+
129
+ ## Contact / context
130
+
131
+ - `src/codegen/emit-scope.ts` — `assertNoModelNameCollision` (the detect-and-error we want to retire)
132
+ - `src/codegen/emit-types.ts` — `referencedNamedTypes` already surfaced (`ExtractedTypeOutput.referencedNamedTypes`, `jsonSchemaToTypeBodyWithRefs`); `extractedTypeNames` not yet surfaced, will be wired when (a)/(b) lands
133
+ - `src/codegen/collect-models.ts`, `emit-models.ts` — `$id` model collection / `_models.ts` emission (unaffected)
134
+ - `docs/handoffs/ajsc-named-type-support.md` — the prior (shipped) `x-named-type` handoff this builds on
@@ -0,0 +1,181 @@
1
+ # Handoff: named-type / verbatim-type support in ajsc
2
+
3
+ > **STATUS: Shipped in ajsc 7.3.0 and adopted in ts-procedures (this cutover).** The placeholder-token workaround described below has been deleted; `substituteModelRefs` now emits `{ 'x-named-type': '<Name>' }` and the codegen reads the converter's `referencedNamedTypes`. The rest of this doc is retained for historical context.
4
+
5
+ **To:** ajsc maintainer
6
+ **From:** ts-procedures codegen (consumer of ajsc as the JSON-Schema → TS/Kotlin/Swift emitter)
7
+ **Date:** 2026-06-05
8
+ **ajsc version inspected:** 7.2.0
9
+ **Priority:** medium — unblocks removing a workaround in ts-procedures, no current breakage
10
+
11
+ ---
12
+
13
+ ## TL;DR
14
+
15
+ ts-procedures needs a way to tell ajsc: *"this subschema is an already-defined named type called `Message` — emit a reference to it (`Message`), don't convert/inline it, and tell me you referenced it so I can add an import."*
16
+
17
+ ajsc 7.2.0 has no such mechanism: a `$ref` to a `$defs` entry is **inlined and re-extracted under the property name** (with no dedup), and there is no verbatim-type escape hatch. We've worked around this downstream with a placeholder-token hack (described below) that we'd like to delete once ajsc supports this directly.
18
+
19
+ The ask is small and **additive** (a new opt-in schema keyword + one new field on the emit result). Nothing changes when the keyword is absent.
20
+
21
+ ---
22
+
23
+ ## Why we need it
24
+
25
+ ts-procedures generates a typed API client from a server's schema. The same domain entity (e.g. `Message`) appears in many route schemas. Today ajsc inlines a fresh structural literal at every site, so `Message` is emitted ~4–6 times under different names in one file, disconnected from each other and from the author's `Message` type. We want **one** `Message` type that every route references.
26
+
27
+ We already know the identity: each such schema carries a stable `$id` (e.g. `urn:msg`) and a `title` (`Message`). So at codegen time we hoist every `$id`-bearing schema into a shared `_models.ts` (emitted via `ajsc.emitTypescript(messageSchema, { rootTypeName: 'Message' })` — which works great), and we want each *route* schema to **reference** `Message` rather than inline it.
28
+
29
+ ## What ajsc 7.2.0 does today (the blocker)
30
+
31
+ Based on inspecting the installed `dist/`:
32
+
33
+ 1. **`$ref` is inlined, not referenced.** The IR layer (`ir/JSONSchemaConverter`) resolves `$ref` against `$defs`/`definitions` by inlining, and rejects non-local refs (`"Only local references are supported"`). With `inlineTypes: false`, the inlined object is then re-extracted **named after the property** — so a schema with `author`, `lastReply`, and `all` all `$ref`-ing the same `Message` emits three duplicate types `Author`, `LastReply`, `All`, with **no deduplication** and no way to make them one `Message`.
34
+
35
+ ```jsonc
36
+ // input
37
+ { "type": "object", "properties": {
38
+ "author": { "$ref": "#/$defs/Message" },
39
+ "lastReply": { "$ref": "#/$defs/Message" } },
40
+ "$defs": { "Message": { "type": "object", "title": "Message",
41
+ "properties": { "id": { "type": "string" } } } } }
42
+ ```
43
+ ```ts
44
+ // ajsc 7.2.0 output (inlineTypes:false) — duplicated, property-named, no dedup
45
+ export type Author = { id?: string };
46
+ export type LastReply = { id?: string };
47
+ export type Root = { author?: Author; lastReply?: LastReply };
48
+ ```
49
+
50
+ 2. **No verbatim-type escape hatch.** Grepping `dist/` for `tsType | x-ts | verbatim | existingType | customType | rawType` returns nothing. We tried `{ tsType: 'Message' }`, `{ "x-tsType": 'Message' }`, `{ $ref: 'Message' }` (bare), `{ type:'object', tsType:'Message' }` — all are ignored (emit `any` / `{ [key:string]: unknown }`) or throw.
51
+
52
+ 3. **`EmitResult.imports` is empty for TypeScript.** The function-form `emitTypescript(schema, opts)` returns `{ code, rootTypeName, extractedTypeNames, imports }`, but `imports` is documented (README ~line 110) as Kotlin/Swift-only — there's no channel to surface "this TS output references external type `Message`."
53
+
54
+ ### Our current workaround (what we want to delete)
55
+
56
+ Because ajsc can't reference a named type, ts-procedures smuggles a sentinel through ajsc and scrapes it back out:
57
+
58
+ 1. Before calling ajsc, replace each `$id`-bearing subschema with `{ const: '__MODELREF__Message__' }`. ajsc emits that verbatim as a string-literal type (`author?: "__MODELREF__Message__"`), and — usefully — never extracts it as a sub-type, and survives inside `Array<…>`.
59
+ 2. After ajsc, a global regex `/["']__MODELREF__([A-Za-z_$][\w$]*)__["']/g` rewrites the tokens to bare names (`author?: Message`) and collects the referenced names so we can emit `import type { Message } from './_models'`.
60
+
61
+ It works and is well-tested, but it encodes a *type identity* as schema *data*, runs it through a code generator, and recovers it from generated *text* with a regex. We'd much rather express the intent directly to ajsc.
62
+
63
+ ---
64
+
65
+ ## Requested feature
66
+
67
+ A new **opt-in schema keyword** that marks a subschema as an already-defined named type. When present, ajsc:
68
+
69
+ 1. emits a **reference** to that name (does not convert/inline the subschema, does not extract a sub-type), and
70
+ 2. **reports** the referenced name on the emit result so the caller can wire an import.
71
+
72
+ ### Keyword name & shape
73
+
74
+ ajsc is multi-target (TS/Kotlin/Swift), so prefer a target-agnostic keyword over a TS-specific one. Suggested:
75
+
76
+ ```jsonc
77
+ { "x-named-type": "Message" }
78
+ ```
79
+
80
+ `x-`-prefixed so it's unambiguously an annotation and ignored by standard JSON-Schema validators. (If you'd rather not use `x-`, `namedType` or `typeRef` are fine — your call on convention.)
81
+
82
+ - **Value = string:** the identifier emitted verbatim in **every** target (`Message` in TS, Kotlin, and Swift). This matches our usage — we use the same PascalCase name across targets.
83
+ - **Optional future extension:** allow an object for per-language names, e.g. `{ "x-named-type": { "ts": "Message", "kotlin": "Message", "swift": "Message" } }`. Not needed by us now; mentioning so the string form can widen later without a breaking change.
84
+
85
+ ### Semantics
86
+
87
+ - The keyword **short-circuits conversion** of that subschema node: emit the bare identifier as the type, in all the positions ajsc already handles a leaf type (direct property, `Array<…>` items, union member, map value, etc.).
88
+ - With `inlineTypes: false`, the named type is **not** added to `extractedTypeNames` (it's external — defined elsewhere).
89
+ - The node is **not** recursed into (its `properties`/`items` are irrelevant once it's a named reference). A `type`/`properties` sitting alongside `x-named-type` should be ignored (or, if you prefer strictness, validated to match — but ignore is simpler and matches our need; we strip the body anyway).
90
+ - **Absent keyword ⇒ zero behaviour change.** Fully backward compatible.
91
+
92
+ ### Reporting referenced names
93
+
94
+ Add referenced external names to the emit result so the caller can build imports. Two acceptable shapes:
95
+
96
+ - **Preferred:** a new field `referencedNamedTypes: string[]` on `EmitResult` (deduped, sorted or insertion-order — either is fine; we sort downstream).
97
+ - **Or:** populate the existing (currently-empty-for-TS) `imports` field with the bare names.
98
+
99
+ A new dedicated field is cleaner than overloading `imports` (which means module paths for Kotlin/Swift).
100
+
101
+ ### Worked example
102
+
103
+ ```jsonc
104
+ // input
105
+ { "type": "object", "properties": {
106
+ "author": { "x-named-type": "Message" },
107
+ "replies": { "type": "array", "items": { "x-named-type": "Message" } },
108
+ "id": { "type": "string" } } }
109
+ ```
110
+
111
+ ```ts
112
+ // desired TS output (inlineTypes:false)
113
+ export type Root = { author?: Message; replies?: Array<Message>; id?: string };
114
+ // emitResult.referencedNamedTypes === ['Message'] // Root is NOT polluted with a Message decl
115
+ ```
116
+
117
+ ```kotlin
118
+ // desired Kotlin output
119
+ data class Root(val author: Message? = null, val replies: List<Message>? = null, val id: String? = null)
120
+ // referencedNamedTypes === ['Message']
121
+ ```
122
+
123
+ ```swift
124
+ // desired Swift output
125
+ struct Root: Codable { let author: Message?; let replies: [Message]?; let id: String? }
126
+ // referencedNamedTypes === ['Message']
127
+ ```
128
+
129
+ In every case ajsc does **not** define `Message` — the caller (ts-procedures) defines it once elsewhere and adds the import/reference.
130
+
131
+ ---
132
+
133
+ ## Where this likely lives in ajsc
134
+
135
+ From the `dist/` surface (you know the real source layout):
136
+
137
+ - **IR conversion** (`ir/JSONSchemaConverter` or equivalent): detect `x-named-type` on a node early and lower it to a new IR node kind — e.g. `NamedTypeRef(name)` — instead of converting the object. This is the one place that needs to intercept before the existing `$ref`-inline / object-extract logic runs.
138
+ - **Each language emitter** (`typescript/`, `kotlin/`, `swift/`): render `NamedTypeRef(name)` as the bare identifier wherever a leaf type is rendered, and **collect** the name into the result.
139
+ - **`EmitResult` type** (`index.d.ts`): add `referencedNamedTypes?: string[]` (or document `imports` carrying them for TS).
140
+ - **`BaseConverterOpts`:** no new option needed — the keyword lives in the schema, not the options. (You *could* add a `validateNamedTypes` strictness flag later; not required.)
141
+
142
+ ---
143
+
144
+ ## Tests worth adding (mirror our spike findings)
145
+
146
+ 1. `x-named-type` on a direct property ⇒ `prop?: Message`, `referencedNamedTypes` includes `Message`, no `Message` extracted.
147
+ 2. Inside `array.items` ⇒ `Array<Message>` (TS) / `List<Message>` (Kotlin) / `[Message]` (Swift).
148
+ 3. Same name referenced by N properties ⇒ deduped to a single `Message` reference, `referencedNamedTypes === ['Message']` (the dedup we can't get today).
149
+ 4. As a whole root schema (`{ "x-named-type": "Message" }` at top level) ⇒ output is just `Message` (our route-response-is-exactly-a-model case).
150
+ 5. Both `inlineTypes: true` and `inlineTypes: false` behave identically for the referenced name (it's a leaf either way).
151
+ 6. Absent keyword ⇒ byte-identical to current output (regression guard).
152
+ 7. Coexists with genuine sibling extraction: a schema with one `x-named-type` property and one ordinary nested object still extracts the ordinary object normally under `inlineTypes:false`.
153
+
154
+ ---
155
+
156
+ ## How ts-procedures will consume it (integration contract)
157
+
158
+ So you can sanity-check the design against the real caller:
159
+
160
+ 1. We already collect every `$id`-bearing subschema into a registry `{ $id, name, schema }` and emit each one standalone via `emitTypescript(schema, { rootTypeName: name })` into `_models.ts`. **No change needed there.**
161
+ 2. For each *route* schema, instead of our placeholder-token substitution, we'll walk the schema and replace each `$id`-bearing subschema with `{ "x-named-type": <name> }` before calling ajsc.
162
+ 3. We read `emitResult.referencedNamedTypes` to emit `import type { Message, … } from './_models'` per scope file.
163
+ 4. We delete `model-refs.ts` (the placeholder/regex module) entirely and the two wrapper functions in `emit-scope.ts`.
164
+
165
+ The contract we depend on: **(a)** a referenced `x-named-type` is never inlined and never extracted, and **(b)** its name is reported back. Those two guarantees are the whole feature.
166
+
167
+ ---
168
+
169
+ ## Versioning
170
+
171
+ Additive and opt-in ⇒ a **minor** bump (e.g. 7.3.0). No migration for existing consumers. ts-procedures will gate on the ajsc version that introduces `referencedNamedTypes` and keep the placeholder-token path as a fallback for one release if you'd like a soft cutover, or hard-switch if you prefer.
172
+
173
+ ---
174
+
175
+ ## Contact / context
176
+
177
+ The placeholder-token workaround, the design rationale, and the empirical ajsc probes live in the ts-procedures repo:
178
+ - `src/codegen/model-refs.ts` — the current substitution we want to remove
179
+ - `src/codegen/emit-models.ts`, `collect-models.ts` — the model collection/emission (unaffected)
180
+ - `docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md` — design (see finding #3)
181
+ - commit `483185e` — the spike that established ajsc's inlining behaviour and chose the workaround