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
@@ -22,7 +22,7 @@ async function validateAjscFormat() {
22
22
  async function resolveTypeBody(
23
23
  schema: Record<string, unknown>,
24
24
  options?: AjscOptions,
25
- ): Promise<string> {
25
+ ): Promise<{ body: string; referencedNamedTypes: string[] }> {
26
26
  await validateAjscFormat()
27
27
 
28
28
  const { TypescriptConverter } = await import('ajsc')
@@ -43,7 +43,9 @@ async function resolveTypeBody(
43
43
  // Remove trailing semicolons and newlines
44
44
  code = code.replace(/;\s*$/, '').trim()
45
45
 
46
- return code
46
+ const referencedNamedTypes = (converter.referencedNamedTypes as string[] | undefined) ?? []
47
+
48
+ return { body: code, referencedNamedTypes }
47
49
  }
48
50
 
49
51
  /**
@@ -60,8 +62,8 @@ export async function jsonSchemaToTypeString(
60
62
  options?: AjscOptions,
61
63
  ): Promise<string | undefined> {
62
64
  if (schema == null) return undefined
63
- const code = await resolveTypeBody(schema, options)
64
- return `export type ${typeName} = ${code}`
65
+ const { body } = await resolveTypeBody(schema, options)
66
+ return `export type ${typeName} = ${body}`
65
67
  }
66
68
 
67
69
  /**
@@ -76,6 +78,23 @@ export async function jsonSchemaToTypeBody(
76
78
  schema: Record<string, unknown> | undefined,
77
79
  options?: AjscOptions,
78
80
  ): Promise<string | undefined> {
81
+ if (schema == null) return undefined
82
+ const { body } = await resolveTypeBody(schema, options)
83
+ return body
84
+ }
85
+
86
+ /**
87
+ * Like {@link jsonSchemaToTypeBody} but also surfaces the ajsc converter's
88
+ * `referencedNamedTypes` — the bare type names emitted verbatim because they
89
+ * carried an `x-named-type` keyword (see `model-refs.ts`). Drives the
90
+ * `_models` import in flat-mode scope emission.
91
+ *
92
+ * Returns undefined for undefined or null schema.
93
+ */
94
+ export async function jsonSchemaToTypeBodyWithRefs(
95
+ schema: Record<string, unknown> | undefined,
96
+ options?: AjscOptions,
97
+ ): Promise<{ body: string; referencedNamedTypes: string[] } | undefined> {
79
98
  if (schema == null) return undefined
80
99
  return resolveTypeBody(schema, options)
81
100
  }
@@ -89,6 +108,19 @@ export interface ExtractedTypeOutput {
89
108
  declarations: string[]
90
109
  /** The root type body (bare literal, e.g., `{ user: User; }`) without the `export type Root =` wrapper. */
91
110
  body: string
111
+ /**
112
+ * Bare type names ajsc emitted verbatim (because they carried an
113
+ * `x-named-type` keyword — see `model-refs.ts`). Drives the `_models`
114
+ * import in namespace-mode scope emission. Empty when no models referenced.
115
+ */
116
+ referencedNamedTypes: string[]
117
+ /**
118
+ * Names ajsc declared/extracted as named sub-types in this output (its own
119
+ * `extractedTypeNames`). Intersecting this with {@link referencedNamedTypes}
120
+ * is ajsc's documented signal that an `x-named-type` model collided with a
121
+ * structural sub-type of the same name (see `assertNoModelNameCollision`).
122
+ */
123
+ extractedTypeNames: string[]
92
124
  }
93
125
 
94
126
  /**
@@ -154,6 +186,9 @@ export async function jsonSchemaToExtractedTypes(
154
186
  inlineTypes: false,
155
187
  })
156
188
 
189
+ const referencedNamedTypes = (converter.referencedNamedTypes as string[] | undefined) ?? []
190
+ const extractedTypeNames = (converter.extractedTypeNames as string[] | undefined) ?? []
191
+
157
192
  const code = (converter.code as string).trim()
158
193
  if (!code) {
159
194
  throw new Error(
@@ -206,7 +241,7 @@ export async function jsonSchemaToExtractedTypes(
206
241
  .trim()
207
242
  }
208
243
 
209
- return { declarations, body }
244
+ return { declarations, body, referencedNamedTypes, extractedTypeNames }
210
245
  }
211
246
 
212
247
  /**
@@ -245,7 +280,8 @@ export function extractedDeclName(decl: string): string | undefined {
245
280
  * name (a latent wrong-type bug that only hides when the shapes happen to match).
246
281
  */
247
282
  export function renameExtractedTypes(
248
- result: ExtractedTypeOutput,
283
+ result: Omit<ExtractedTypeOutput, 'referencedNamedTypes' | 'extractedTypeNames'> &
284
+ Partial<Pick<ExtractedTypeOutput, 'referencedNamedTypes' | 'extractedTypeNames'>>,
249
285
  taken: Set<string>,
250
286
  ): ExtractedTypeOutput {
251
287
  // Work on a mutable copy so we can patch cross-references after renaming.
@@ -293,5 +329,10 @@ export function renameExtractedTypes(
293
329
  }
294
330
  }
295
331
 
296
- return { declarations, body }
332
+ return {
333
+ declarations,
334
+ body,
335
+ referencedNamedTypes: result.referencedNamedTypes ?? [],
336
+ extractedTypeNames: result.extractedTypeNames ?? [],
337
+ }
297
338
  }
@@ -1,6 +1,7 @@
1
1
  import { resolveEnvelope, type ResolveInput } from './resolve-envelope.js'
2
2
  import { runPipeline, type GeneratedFile } from './pipeline.js'
3
3
  import type { AjscOptions } from './emit-types.js'
4
+ import type { SharedTypesImportMap } from './collect-models.js'
4
5
  import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
5
6
  import type { SwiftEmitter } from './targets/swift/ajsc-adapter.js'
6
7
 
@@ -13,6 +14,20 @@ export interface GenerateClientOptions extends ResolveInput {
13
14
  selfContained?: boolean
14
15
  serviceName?: string
15
16
  cleanOutDir?: boolean
17
+ /** Hoist `$id`-bearing schemas into a shared `_models.ts` and reference them from scopes (TS target). */
18
+ shareModels?: boolean
19
+ /** Maps a model `$id` to an external import so a shared model is re-exported rather than generated. */
20
+ sharedTypesImport?: SharedTypesImportMap
21
+ /** Single module every $id-bearing model re-exports from (convention; `sharedTypesImport` entries override). */
22
+ sharedModelsModule?: string
23
+ /** Hard-fail codegen if any $id-bearing model would be generated as a local structural twin. */
24
+ strictSharedModels?: boolean
25
+ /**
26
+ * Sink for non-error progress messages (e.g. the shared-models summary).
27
+ * Omitted by default so programmatic callers produce no console output; pass
28
+ * `console.log` to opt in. The CLI wires this to stdout.
29
+ */
30
+ logger?: (message: string) => void
16
31
  target?: 'ts' | 'kotlin' | 'swift'
17
32
  kotlinPackage?: string
18
33
  kotlinSerializer?: 'kotlinx' | 'none'
@@ -37,6 +52,11 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
37
52
  selfContained: options.selfContained,
38
53
  serviceName: options.serviceName,
39
54
  cleanOutDir: options.cleanOutDir,
55
+ shareModels: options.shareModels,
56
+ sharedTypesImport: options.sharedTypesImport,
57
+ sharedModelsModule: options.sharedModelsModule,
58
+ strictSharedModels: options.strictSharedModels,
59
+ logger: options.logger,
40
60
  target: options.target,
41
61
  kotlinPackage: options.kotlinPackage,
42
62
  kotlinSerializer: options.kotlinSerializer,
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { substituteModelRefs } from './model-refs.js'
3
+
4
+ const message = { type: 'object', $id: 'urn:msg', title: 'Message', properties: { id: { type: 'string' } } }
5
+
6
+ describe('substituteModelRefs', () => {
7
+ it('replaces an $id subschema with an x-named-type node keyed by model name', () => {
8
+ const schema = { type: 'object', properties: { author: message, tags: { type: 'array', items: message } } }
9
+ const idToName = new Map([['urn:msg', 'Message']])
10
+ const { schema: out, referenced } = substituteModelRefs(schema, idToName)
11
+ expect((out as any).properties.author).toEqual({ 'x-named-type': 'Message' })
12
+ expect((out as any).properties.tags.items).toEqual({ 'x-named-type': 'Message' })
13
+ expect([...referenced]).toEqual(['Message'])
14
+ })
15
+
16
+ it('does not mutate the input schema', () => {
17
+ const schema = { type: 'object', properties: { author: message } }
18
+ const before = JSON.stringify(schema)
19
+ substituteModelRefs(schema, new Map([['urn:msg', 'Message']]))
20
+ expect(JSON.stringify(schema)).toBe(before)
21
+ })
22
+
23
+ it('leaves the schema untouched when no $id is in the registry', () => {
24
+ const schema = { type: 'object', properties: { author: message } }
25
+ const { schema: out, referenced } = substituteModelRefs(schema, new Map())
26
+ expect(out).toEqual(schema)
27
+ expect(referenced.size).toBe(0)
28
+ })
29
+
30
+ it('does NOT replace the node when its own $id is the registry root being emitted (optional skipSelfId)', () => {
31
+ // When emitting the Message model itself, we don't want Message to become a ref to itself.
32
+ const idToName = new Map([['urn:msg', 'Message']])
33
+ const { schema: out } = substituteModelRefs(message, idToName, 'urn:msg')
34
+ expect(out).not.toHaveProperty('x-named-type')
35
+ expect(out.$id).toBe('urn:msg')
36
+ })
37
+ })
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared-`$id` model referencing via ajsc's `x-named-type` keyword (ajsc ≥7.3.0).
3
+ *
4
+ * ajsc inlines plain `$ref`s, so to make a route reference a hoisted model by
5
+ * name instead of re-inlining its shape, {@link substituteModelRefs} rewrites
6
+ * each `$id` subschema (BEFORE ajsc runs) into `{ 'x-named-type': '<Name>' }`.
7
+ * ajsc then emits a bare verbatim reference to `<Name>` (surviving arrays and
8
+ * both `inlineTypes` modes, never extracted as a sub-type) and reports it via
9
+ * the converter's `referencedNamedTypes` — which the emitter uses to add the
10
+ * `import type { … } from './_models'` line.
11
+ */
12
+
13
+ /**
14
+ * Returns a deep-cloned schema where every object node carrying a `$id` present
15
+ * in `idToName` is replaced by `{ 'x-named-type': '<Name>' }`. The input is
16
+ * never mutated.
17
+ *
18
+ * - Replaced nodes are NOT recursed into (they're gone).
19
+ * - A node whose `$id === skipSelfId` is left in place (used when emitting that
20
+ * model itself), but its CHILDREN are still recursed so nested OTHER models are
21
+ * still referenced.
22
+ *
23
+ * @returns the rewritten schema and the set of referenced model names.
24
+ */
25
+ export function substituteModelRefs(
26
+ schema: Record<string, unknown>,
27
+ idToName: Map<string, string>,
28
+ skipSelfId?: string
29
+ ): { schema: Record<string, unknown>; referenced: Set<string> } {
30
+ const referenced = new Set<string>()
31
+
32
+ const walk = (node: unknown): unknown => {
33
+ if (Array.isArray(node)) {
34
+ return node.map((item) => walk(item))
35
+ }
36
+ if (!node || typeof node !== 'object') return node
37
+
38
+ const obj = node as Record<string, unknown>
39
+ const id = typeof obj.$id === 'string' ? obj.$id : undefined
40
+
41
+ if (id !== undefined && id !== skipSelfId && idToName.has(id)) {
42
+ const name = idToName.get(id)!
43
+ referenced.add(name)
44
+ // Replace entirely — do not recurse into a node that's being swapped out.
45
+ return { 'x-named-type': name }
46
+ }
47
+
48
+ // Either no $id, or the self node we're emitting: deep-clone children and recurse.
49
+ const out: Record<string, unknown> = {}
50
+ for (const [k, v] of Object.entries(obj)) {
51
+ out[k] = walk(v)
52
+ }
53
+ return out
54
+ }
55
+
56
+ return { schema: walk(structuredClone(schema)) as Record<string, unknown>, referenced }
57
+ }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import type { DocEnvelope } from '../implementations/types.js'
3
3
  import type { AjscOptions } from './emit-types.js'
4
4
  import { groupRoutesByScope } from './group-routes.js'
5
+ import type { SharedTypesImportMap } from './collect-models.js'
5
6
  import { validateServiceName } from './naming.js'
6
7
  import type { GeneratedFile } from './targets/_shared/write-files.js'
7
8
  import type { KotlinEmitter } from './targets/kotlin/ajsc-adapter.js'
@@ -20,6 +21,12 @@ export interface PipelineOptions {
20
21
  selfContained?: boolean
21
22
  serviceName?: string
22
23
  cleanOutDir?: boolean
24
+ shareModels?: boolean
25
+ sharedTypesImport?: SharedTypesImportMap
26
+ sharedModelsModule?: string
27
+ strictSharedModels?: boolean
28
+ /** Sink for non-error progress messages (shared-models summary). Defaults to silent. */
29
+ logger?: (message: string) => void
23
30
  target?: 'ts' | 'kotlin' | 'swift'
24
31
  kotlinPackage?: string
25
32
  kotlinSerializer?: 'kotlinx' | 'none'
@@ -43,7 +50,7 @@ export type { GeneratedFile } from './targets/_shared/write-files.js'
43
50
  * `_shared/write-files.ts`).
44
51
  */
45
52
  export async function runPipeline(options: PipelineOptions): Promise<GeneratedFile[]> {
46
- const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = true } = options
53
+ const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = true, shareModels = true } = options
47
54
  const serviceName = options.serviceName ?? 'Api'
48
55
  validateServiceName(serviceName)
49
56
 
@@ -67,6 +74,11 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
67
74
  namespaceTypes,
68
75
  selfContained,
69
76
  cleanOutDir,
77
+ shareModels,
78
+ sharedTypesImport: options.sharedTypesImport,
79
+ sharedModelsModule: options.sharedModelsModule,
80
+ strictSharedModels: options.strictSharedModels,
81
+ logger: options.logger,
70
82
  }
71
83
 
72
84
  switch (options.target) {
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { walkSubschemas } from './schema-walk.js'
3
+
4
+ describe('walkSubschemas', () => {
5
+ it('visits every plain-object node, including nested and array-nested ones', () => {
6
+ const tree = {
7
+ type: 'object',
8
+ properties: {
9
+ a: { type: 'string' },
10
+ b: { type: 'array', items: { type: 'number' } },
11
+ },
12
+ anyOf: [{ const: 1 }, { const: 2 }],
13
+ }
14
+ const seen: Array<Record<string, unknown>> = []
15
+ walkSubschemas(tree, (obj) => seen.push(obj))
16
+ // root + properties + a + b + items + 2 anyOf entries = 7 object nodes
17
+ expect(seen).toHaveLength(7)
18
+ expect(seen[0]).toBe(tree)
19
+ expect(seen).toContainEqual({ type: 'number' })
20
+ expect(seen).toContainEqual({ const: 1 })
21
+ })
22
+
23
+ it('does not invoke visit on arrays or primitives', () => {
24
+ const seen: Array<Record<string, unknown>> = []
25
+ walkSubschemas([1, 'two', { x: 1 }], (obj) => seen.push(obj))
26
+ expect(seen).toEqual([{ x: 1 }])
27
+ })
28
+
29
+ it('is a no-op for null, primitives, and bare arrays of primitives', () => {
30
+ const seen: Array<Record<string, unknown>> = []
31
+ walkSubschemas(null, (obj) => seen.push(obj))
32
+ walkSubschemas('str', (obj) => seen.push(obj))
33
+ walkSubschemas(42, (obj) => seen.push(obj))
34
+ walkSubschemas(['a', 'b'], (obj) => seen.push(obj))
35
+ expect(seen).toEqual([])
36
+ })
37
+ })
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Read-only recursive traversal of a JSON-Schema-shaped value.
3
+ *
4
+ * Recurses into arrays and into the values of plain objects, invoking `visit`
5
+ * on every plain-object node encountered (including the root, if it is one).
6
+ * Arrays are not passed to `visit` — only object nodes are. The traversal does
7
+ * not mutate the tree and does not prune: a visited node's children are always
8
+ * recursed.
9
+ *
10
+ * For mutation/substitution semantics (deep-clone, replace-and-prune), see
11
+ * `substituteModelRefs` in `model-refs.ts`, which keeps its own bespoke walk.
12
+ */
13
+ export function walkSubschemas(node: unknown, visit: (obj: Record<string, unknown>) => void): void {
14
+ if (Array.isArray(node)) {
15
+ for (const item of node) walkSubschemas(item, visit)
16
+ return
17
+ }
18
+ if (!node || typeof node !== 'object') return
19
+
20
+ const obj = node as Record<string, unknown>
21
+ visit(obj)
22
+ for (const value of Object.values(obj)) walkSubschemas(value, visit)
23
+ }
@@ -1,6 +1,7 @@
1
1
  import type { DocEnvelope } from '../../../implementations/types.js'
2
2
  import type { ScopeGroup } from '../../group-routes.js'
3
3
  import type { AjscOptions } from '../../emit-types.js'
4
+ import type { SharedTypesImportMap } from '../../collect-models.js'
4
5
 
5
6
  /**
6
7
  * Inputs the dispatcher prepares once and forwards to every per-target
@@ -25,4 +26,18 @@ export interface TargetRunInput {
25
26
  namespaceTypes?: boolean
26
27
  selfContained?: boolean
27
28
  cleanOutDir?: boolean
29
+ /** Hoist `$id`-bearing schemas into a shared `_models.ts` (TS target only). */
30
+ shareModels?: boolean
31
+ /** Maps a model `$id` to an external import (re-exported instead of generated). */
32
+ sharedTypesImport?: SharedTypesImportMap
33
+ /** Single module every $id-bearing model re-exports from (convention; map entries override). */
34
+ sharedModelsModule?: string
35
+ /** When true, hard-fail if any $id-bearing model has no shared-type mapping (would be a local twin). */
36
+ strictSharedModels?: boolean
37
+ /**
38
+ * Sink for non-error progress messages (e.g. the shared-models summary).
39
+ * Defaults to undefined — programmatic callers stay silent; the CLI injects
40
+ * `console.log`. Output belongs to the caller, not the run module.
41
+ */
42
+ logger?: (message: string) => void
28
43
  }
@@ -14,6 +14,8 @@ import { emitIndexFile } from '../../emit-index.js'
14
14
  import { emitErrorsFile } from '../../emit-errors.js'
15
15
  import { emitClientTypesFile } from '../../emit-client-types.js'
16
16
  import { emitClientRuntimeFile } from '../../emit-client-runtime.js'
17
+ import { collectModels, resolveModelImports } from '../../collect-models.js'
18
+ import { emitModelsFile } from '../../emit-models.js'
17
19
 
18
20
  export type TsRunInput = TargetRunInput
19
21
 
@@ -30,7 +32,12 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
30
32
  namespaceTypes = false,
31
33
  selfContained = false,
32
34
  cleanOutDir = false,
35
+ sharedTypesImport,
36
+ sharedModelsModule,
37
+ strictSharedModels = false,
38
+ logger,
33
39
  } = input
40
+ const shareModels = input.shareModels ?? false
34
41
 
35
42
  const hashComment = `// Source hash: ${hash}`
36
43
 
@@ -51,6 +58,39 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
51
58
  }
52
59
  }
53
60
 
61
+ // Shared models: hoist every `$id`-bearing schema into `_models.ts` and pass an
62
+ // id→name map so scope emission references them instead of inlining. The map
63
+ // covers ALL models (generated AND externally-imported) since scopes always
64
+ // import shared types from `./_models`.
65
+ const models = shareModels
66
+ ? resolveModelImports(collectModels(envelope.routes), { sharedTypesImport, sharedModelsModule })
67
+ : []
68
+ const idToModelName = new Map(models.map((m) => [m.id, m.name]))
69
+
70
+ if (shareModels) {
71
+ for (const group of groups) {
72
+ if (group.scopeKey === '_models') {
73
+ throw new Error(
74
+ `[ts-procedures-codegen] Scope "_models" conflicts with shared-models reserved filename "_models.ts". Rename the scope to avoid collision.`,
75
+ )
76
+ }
77
+ }
78
+ }
79
+
80
+ if (shareModels && models.length > 0) {
81
+ const generated = models.filter((m) => m.import == null)
82
+ if (strictSharedModels && generated.length > 0) {
83
+ const list = generated.map((m) => `"${m.id}" (${m.name})`).join(', ')
84
+ throw new Error(
85
+ `[ts-procedures-codegen] --strict-shared-models: ${generated.length} $id-bearing schema(s) have no shared-type mapping and would be generated as local structural twins: ${list}. Add a sharedTypesImport entry, set sharedModelsModule, or drop --strict-shared-models.`,
86
+ )
87
+ }
88
+ const reExported = models.length - generated.length
89
+ logger?.(
90
+ `[ts-procedures-codegen] Shared models: ${models.length} total — ${reExported} re-exported, ${generated.length} generated locally.`,
91
+ )
92
+ }
93
+
54
94
  const files: GeneratedFile[] = []
55
95
 
56
96
  for (const group of groups) {
@@ -60,6 +100,7 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
60
100
  namespaceTypes,
61
101
  serviceName,
62
102
  errorKeys: errorKeys.size > 0 ? errorKeys : undefined,
103
+ idToModelName,
63
104
  })
64
105
  const lines = rawCode.split('\n')
65
106
  lines.splice(1, 0, hashComment)
@@ -67,6 +108,15 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
67
108
  files.push({ path: join(outDir, `${group.scopeKey}.ts`), code })
68
109
  }
69
110
 
111
+ if (models.length > 0) {
112
+ const modelsCode = await emitModelsFile(models, { ajsc: ajscOpts })
113
+ if (modelsCode != null) {
114
+ const ml = modelsCode.split('\n')
115
+ ml.splice(1, 0, hashComment)
116
+ files.push({ path: join(outDir, '_models.ts'), code: ml.join('\n') })
117
+ }
118
+ }
119
+
70
120
  const errorsCode = await emitErrorsFile(envelope.errors, {
71
121
  ajsc: ajscOpts,
72
122
  clientImportPath,