ts-procedures 8.2.1 → 8.4.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 (146) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +31 -9
  2. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +3 -1
  3. package/agent_config/claude-code/skills/ts-procedures/patterns.md +30 -6
  4. package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
  5. package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
  6. package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
  7. package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
  8. package/agent_config/copilot/copilot-instructions.md +10 -6
  9. package/agent_config/cursor/cursorrules +10 -6
  10. package/build/client/call.js +1 -1
  11. package/build/client/call.js.map +1 -1
  12. package/build/client/index.d.ts +1 -1
  13. package/build/client/index.js +23 -1
  14. package/build/client/index.js.map +1 -1
  15. package/build/client/index.test.js +87 -0
  16. package/build/client/index.test.js.map +1 -1
  17. package/build/client/resolve-options.d.ts +5 -4
  18. package/build/client/resolve-options.js +18 -7
  19. package/build/client/resolve-options.js.map +1 -1
  20. package/build/client/resolve-options.test.js +53 -24
  21. package/build/client/resolve-options.test.js.map +1 -1
  22. package/build/client/stream.js +1 -1
  23. package/build/client/stream.js.map +1 -1
  24. package/build/client/types.d.ts +31 -3
  25. package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
  26. package/build/codegen/__fixtures__/make-envelope.js +38 -0
  27. package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
  28. package/build/codegen/bin/cli.d.ts +11 -0
  29. package/build/codegen/bin/cli.js +30 -21
  30. package/build/codegen/bin/cli.js.map +1 -1
  31. package/build/codegen/bin/cli.test.js +36 -1
  32. package/build/codegen/bin/cli.test.js.map +1 -1
  33. package/build/codegen/bin/flag-specs.d.ts +10 -0
  34. package/build/codegen/bin/flag-specs.js +60 -0
  35. package/build/codegen/bin/flag-specs.js.map +1 -0
  36. package/build/codegen/bin/flag-specs.test.d.ts +1 -0
  37. package/build/codegen/bin/flag-specs.test.js +26 -0
  38. package/build/codegen/bin/flag-specs.test.js.map +1 -0
  39. package/build/codegen/collect-models.d.ts +37 -0
  40. package/build/codegen/collect-models.js +74 -0
  41. package/build/codegen/collect-models.js.map +1 -0
  42. package/build/codegen/collect-models.test.d.ts +1 -0
  43. package/build/codegen/collect-models.test.js +40 -0
  44. package/build/codegen/collect-models.test.js.map +1 -0
  45. package/build/codegen/emit-client-runtime.js +1 -0
  46. package/build/codegen/emit-client-runtime.js.map +1 -1
  47. package/build/codegen/emit-errors.integration.test.js +22 -0
  48. package/build/codegen/emit-errors.integration.test.js.map +1 -1
  49. package/build/codegen/emit-models.d.ts +26 -0
  50. package/build/codegen/emit-models.js +53 -0
  51. package/build/codegen/emit-models.js.map +1 -0
  52. package/build/codegen/emit-models.test.d.ts +1 -0
  53. package/build/codegen/emit-models.test.js +42 -0
  54. package/build/codegen/emit-models.test.js.map +1 -0
  55. package/build/codegen/emit-scope.d.ts +10 -0
  56. package/build/codegen/emit-scope.js +119 -34
  57. package/build/codegen/emit-scope.js.map +1 -1
  58. package/build/codegen/emit-types.d.ts +26 -1
  59. package/build/codegen/emit-types.js +27 -5
  60. package/build/codegen/emit-types.js.map +1 -1
  61. package/build/codegen/index.d.ts +5 -0
  62. package/build/codegen/index.js +2 -0
  63. package/build/codegen/index.js.map +1 -1
  64. package/build/codegen/model-refs.d.ts +27 -0
  65. package/build/codegen/model-refs.js +49 -0
  66. package/build/codegen/model-refs.js.map +1 -0
  67. package/build/codegen/model-refs.test.d.ts +1 -0
  68. package/build/codegen/model-refs.test.js +33 -0
  69. package/build/codegen/model-refs.test.js.map +1 -0
  70. package/build/codegen/pipeline.d.ts +3 -0
  71. package/build/codegen/pipeline.js +3 -1
  72. package/build/codegen/pipeline.js.map +1 -1
  73. package/build/codegen/schema-walk.d.ts +13 -0
  74. package/build/codegen/schema-walk.js +26 -0
  75. package/build/codegen/schema-walk.js.map +1 -0
  76. package/build/codegen/schema-walk.test.d.ts +1 -0
  77. package/build/codegen/schema-walk.test.js +35 -0
  78. package/build/codegen/schema-walk.test.js.map +1 -0
  79. package/build/codegen/targets/_shared/target-run.d.ts +5 -0
  80. package/build/codegen/targets/ts/run.js +28 -1
  81. package/build/codegen/targets/ts/run.js.map +1 -1
  82. package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
  83. package/build/codegen/targets/ts/shared-models.test.js +258 -0
  84. package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
  85. package/build/doc-envelope.d.ts +13 -0
  86. package/build/doc-envelope.js +23 -0
  87. package/build/doc-envelope.js.map +1 -0
  88. package/build/doc-envelope.test.d.ts +1 -0
  89. package/build/doc-envelope.test.js +31 -0
  90. package/build/doc-envelope.test.js.map +1 -0
  91. package/build/exports.d.ts +2 -0
  92. package/build/exports.js +1 -0
  93. package/build/exports.js.map +1 -1
  94. package/build/implementations/http/error-taxonomy.d.ts +40 -0
  95. package/build/implementations/http/error-taxonomy.js +57 -5
  96. package/build/implementations/http/error-taxonomy.js.map +1 -1
  97. package/build/implementations/http/error-taxonomy.test.js +95 -1
  98. package/build/implementations/http/error-taxonomy.test.js.map +1 -1
  99. package/build/implementations/http/hono/handlers/http.js +19 -24
  100. package/build/implementations/http/hono/handlers/http.js.map +1 -1
  101. package/build/implementations/http/hono/handlers/http.test.js +64 -1
  102. package/build/implementations/http/hono/handlers/http.test.js.map +1 -1
  103. package/docs/client-and-codegen.md +109 -0
  104. package/docs/core.md +2 -0
  105. package/docs/handoffs/ajsc-named-type-collision.md +134 -0
  106. package/docs/handoffs/ajsc-named-type-support.md +181 -0
  107. package/docs/http-integrations.md +4 -0
  108. package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
  109. package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
  110. package/package.json +2 -2
  111. package/src/client/call.ts +1 -1
  112. package/src/client/index.test.ts +98 -0
  113. package/src/client/index.ts +32 -1
  114. package/src/client/resolve-options.test.ts +73 -26
  115. package/src/client/resolve-options.ts +23 -9
  116. package/src/client/stream.ts +1 -1
  117. package/src/client/types.ts +34 -3
  118. package/src/codegen/__fixtures__/make-envelope.ts +89 -0
  119. package/src/codegen/bin/cli.test.ts +38 -1
  120. package/src/codegen/bin/cli.ts +33 -22
  121. package/src/codegen/bin/flag-specs.test.ts +27 -0
  122. package/src/codegen/bin/flag-specs.ts +69 -0
  123. package/src/codegen/collect-models.test.ts +46 -0
  124. package/src/codegen/collect-models.ts +108 -0
  125. package/src/codegen/emit-client-runtime.ts +1 -0
  126. package/src/codegen/emit-errors.integration.test.ts +26 -0
  127. package/src/codegen/emit-models.test.ts +48 -0
  128. package/src/codegen/emit-models.ts +63 -0
  129. package/src/codegen/emit-scope.ts +145 -33
  130. package/src/codegen/emit-types.ts +48 -7
  131. package/src/codegen/index.ts +7 -0
  132. package/src/codegen/model-refs.test.ts +37 -0
  133. package/src/codegen/model-refs.ts +57 -0
  134. package/src/codegen/pipeline.ts +6 -1
  135. package/src/codegen/schema-walk.test.ts +37 -0
  136. package/src/codegen/schema-walk.ts +23 -0
  137. package/src/codegen/targets/_shared/target-run.ts +5 -0
  138. package/src/codegen/targets/ts/run.ts +33 -0
  139. package/src/codegen/targets/ts/shared-models.test.ts +283 -0
  140. package/src/doc-envelope.test.ts +35 -0
  141. package/src/doc-envelope.ts +30 -0
  142. package/src/exports.ts +2 -0
  143. package/src/implementations/http/error-taxonomy.test.ts +111 -0
  144. package/src/implementations/http/error-taxonomy.ts +60 -5
  145. package/src/implementations/http/hono/handlers/http.test.ts +69 -1
  146. package/src/implementations/http/hono/handlers/http.ts +19 -21
@@ -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,8 @@ export interface PipelineOptions {
20
21
  selfContained?: boolean
21
22
  serviceName?: string
22
23
  cleanOutDir?: boolean
24
+ shareModels?: boolean
25
+ sharedTypesImport?: SharedTypesImportMap
23
26
  target?: 'ts' | 'kotlin' | 'swift'
24
27
  kotlinPackage?: string
25
28
  kotlinSerializer?: 'kotlinx' | 'none'
@@ -43,7 +46,7 @@ export type { GeneratedFile } from './targets/_shared/write-files.js'
43
46
  * `_shared/write-files.ts`).
44
47
  */
45
48
  export async function runPipeline(options: PipelineOptions): Promise<GeneratedFile[]> {
46
- const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = true } = options
49
+ const { envelope, outDir, ajsc: ajscOpts, dryRun = false, namespaceTypes = false, selfContained = false, cleanOutDir = true, shareModels = true } = options
47
50
  const serviceName = options.serviceName ?? 'Api'
48
51
  validateServiceName(serviceName)
49
52
 
@@ -67,6 +70,8 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
67
70
  namespaceTypes,
68
71
  selfContained,
69
72
  cleanOutDir,
73
+ shareModels,
74
+ sharedTypesImport: options.sharedTypesImport,
70
75
  }
71
76
 
72
77
  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,8 @@ 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
28
33
  }
@@ -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,9 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
30
32
  namespaceTypes = false,
31
33
  selfContained = false,
32
34
  cleanOutDir = false,
35
+ sharedTypesImport,
33
36
  } = input
37
+ const shareModels = input.shareModels ?? false
34
38
 
35
39
  const hashComment = `// Source hash: ${hash}`
36
40
 
@@ -51,6 +55,25 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
51
55
  }
52
56
  }
53
57
 
58
+ // Shared models: hoist every `$id`-bearing schema into `_models.ts` and pass an
59
+ // id→name map so scope emission references them instead of inlining. The map
60
+ // covers ALL models (generated AND externally-imported) since scopes always
61
+ // import shared types from `./_models`.
62
+ const models = shareModels
63
+ ? resolveModelImports(collectModels(envelope.routes), sharedTypesImport)
64
+ : []
65
+ const idToModelName = new Map(models.map((m) => [m.id, m.name]))
66
+
67
+ if (shareModels) {
68
+ for (const group of groups) {
69
+ if (group.scopeKey === '_models') {
70
+ throw new Error(
71
+ `[ts-procedures-codegen] Scope "_models" conflicts with shared-models reserved filename "_models.ts". Rename the scope to avoid collision.`,
72
+ )
73
+ }
74
+ }
75
+ }
76
+
54
77
  const files: GeneratedFile[] = []
55
78
 
56
79
  for (const group of groups) {
@@ -60,6 +83,7 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
60
83
  namespaceTypes,
61
84
  serviceName,
62
85
  errorKeys: errorKeys.size > 0 ? errorKeys : undefined,
86
+ idToModelName,
63
87
  })
64
88
  const lines = rawCode.split('\n')
65
89
  lines.splice(1, 0, hashComment)
@@ -67,6 +91,15 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
67
91
  files.push({ path: join(outDir, `${group.scopeKey}.ts`), code })
68
92
  }
69
93
 
94
+ if (models.length > 0) {
95
+ const modelsCode = await emitModelsFile(models, { ajsc: ajscOpts })
96
+ if (modelsCode != null) {
97
+ const ml = modelsCode.split('\n')
98
+ ml.splice(1, 0, hashComment)
99
+ files.push({ path: join(outDir, '_models.ts'), code: ml.join('\n') })
100
+ }
101
+ }
102
+
70
103
  const errorsCode = await emitErrorsFile(envelope.errors, {
71
104
  ajsc: ajscOpts,
72
105
  clientImportPath,
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { runPipeline } from '../../pipeline.js'
3
+ import type { DocEnvelope } from '../../../implementations/types.js'
4
+ import type { GeneratedFile } from '../_shared/write-files.js'
5
+ import { makeApiRoute, makeEnvelope } from '../../__fixtures__/make-envelope.js'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Fixtures
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** A reusable `$id`-bearing schema — appears verbatim in multiple routes/scopes. */
12
+ const messageModel = {
13
+ $id: 'urn:msg',
14
+ title: 'Message',
15
+ type: 'object',
16
+ properties: {
17
+ id: { type: 'string' },
18
+ body: { type: 'string' },
19
+ },
20
+ required: ['id', 'body'],
21
+ } as const
22
+
23
+ /**
24
+ * The same `urn:msg` model appears as a nested property in two scopes
25
+ * (`messages`, `threads`), and once as the WHOLE response body of a route
26
+ * (top-level `$id`).
27
+ */
28
+ function modelEnvelope(): DocEnvelope {
29
+ return makeEnvelope([
30
+ makeApiRoute({
31
+ name: 'GetMessage',
32
+ scope: 'messages',
33
+ fullPath: '/messages/:id',
34
+ jsonSchema: {
35
+ req: {
36
+ pathParams: {
37
+ type: 'object',
38
+ properties: { id: { type: 'string' } },
39
+ required: ['id'],
40
+ },
41
+ },
42
+ // Whole response body IS the model (top-level $id) → `Response = Message`.
43
+ res: { body: { ...messageModel } },
44
+ },
45
+ }),
46
+ makeApiRoute({
47
+ name: 'ListMessages',
48
+ scope: 'messages',
49
+ fullPath: '/messages',
50
+ jsonSchema: {
51
+ res: {
52
+ body: {
53
+ type: 'object',
54
+ properties: {
55
+ // Model nested inside an array → referenced, not inlined.
56
+ items: { type: 'array', items: { ...messageModel } },
57
+ },
58
+ required: ['items'],
59
+ },
60
+ },
61
+ },
62
+ }),
63
+ makeApiRoute({
64
+ name: 'GetThread',
65
+ scope: 'threads',
66
+ fullPath: '/threads/:id',
67
+ jsonSchema: {
68
+ res: {
69
+ body: {
70
+ type: 'object',
71
+ properties: {
72
+ // Same model in a SECOND scope.
73
+ latest: { ...messageModel },
74
+ },
75
+ required: ['latest'],
76
+ },
77
+ },
78
+ },
79
+ }),
80
+ ])
81
+ }
82
+
83
+ /** Identical to {@link modelEnvelope} but with every `$id`/`title` stripped. */
84
+ function noModelEnvelope(): DocEnvelope {
85
+ return makeEnvelope([
86
+ makeApiRoute({
87
+ name: 'GetMessage',
88
+ scope: 'messages',
89
+ fullPath: '/messages/:id',
90
+ jsonSchema: {
91
+ req: {
92
+ pathParams: {
93
+ type: 'object',
94
+ properties: { id: { type: 'string' } },
95
+ required: ['id'],
96
+ },
97
+ },
98
+ res: {
99
+ body: {
100
+ type: 'object',
101
+ properties: { id: { type: 'string' }, body: { type: 'string' } },
102
+ required: ['id', 'body'],
103
+ },
104
+ },
105
+ },
106
+ }),
107
+ ])
108
+ }
109
+
110
+ function findFile(files: GeneratedFile[], suffix: string): GeneratedFile | undefined {
111
+ return files.find((f) => f.path.endsWith(suffix))
112
+ }
113
+
114
+ const countMatches = (haystack: string, re: RegExp): number =>
115
+ (haystack.match(re) ?? []).length
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Tests
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe('shared models (TS target, ajsc x-named-type)', () => {
122
+ it('shareModels:true emits _models.ts with exactly one Message type; scopes reference it', async () => {
123
+ const files = await runPipeline({
124
+ envelope: modelEnvelope(),
125
+ outDir: 'out',
126
+ dryRun: true,
127
+ shareModels: true,
128
+ namespaceTypes: true,
129
+ selfContained: false,
130
+ })
131
+
132
+ const modelsFile = findFile(files, '_models.ts')
133
+ expect(modelsFile).toBeDefined()
134
+ expect(countMatches(modelsFile!.code, /export type Message =/g)).toBe(1)
135
+
136
+ // Both scopes that use the model import it and reference Message by name.
137
+ const messages = findFile(files, 'messages.ts')!
138
+ const threads = findFile(files, 'threads.ts')!
139
+ for (const scope of [messages, threads]) {
140
+ expect(scope.code).toContain("from './_models'")
141
+ expect(scope.code).toContain('Message')
142
+ // No inlined model object literal — the body shape lives only in _models.ts.
143
+ expect(scope.code).not.toMatch(/body:\s*string;[^}]*}\s*\)/)
144
+ }
145
+ })
146
+
147
+ it('a route whose entire response IS the model emits `Response = Message`', async () => {
148
+ const files = await runPipeline({
149
+ envelope: modelEnvelope(),
150
+ outDir: 'out',
151
+ dryRun: true,
152
+ shareModels: true,
153
+ namespaceTypes: true,
154
+ })
155
+ const messages = findFile(files, 'messages.ts')!
156
+ // GetMessage's response body IS the whole model (top-level $id) → the Body
157
+ // alias is exactly `Message`, the single token replacing the whole schema.
158
+ expect(messages.code).toMatch(/export type Body = Message\b/)
159
+ // And the nested-array case references the same model inside Array<…>.
160
+ expect(messages.code).toMatch(/Array<Message>/)
161
+ })
162
+
163
+ it('shareModels:false emits NO _models.ts and inlines as before', async () => {
164
+ const files = await runPipeline({
165
+ envelope: modelEnvelope(),
166
+ outDir: 'out',
167
+ dryRun: true,
168
+ shareModels: false,
169
+ namespaceTypes: true,
170
+ })
171
+ expect(findFile(files, '_models.ts')).toBeUndefined()
172
+ const messages = findFile(files, 'messages.ts')!
173
+ // The model is inlined: its properties appear in the scope, no _models import.
174
+ expect(messages.code).not.toContain("from './_models'")
175
+ expect(messages.code).toContain('body')
176
+ })
177
+
178
+ it('regression: a no-$id envelope produces byte-identical scope output for true vs false', async () => {
179
+ const on = await runPipeline({
180
+ envelope: noModelEnvelope(),
181
+ outDir: 'out',
182
+ dryRun: true,
183
+ shareModels: true,
184
+ namespaceTypes: true,
185
+ })
186
+ const off = await runPipeline({
187
+ envelope: noModelEnvelope(),
188
+ outDir: 'out',
189
+ dryRun: true,
190
+ shareModels: false,
191
+ namespaceTypes: true,
192
+ })
193
+
194
+ // No models exist, so neither run emits _models.ts.
195
+ expect(findFile(on, '_models.ts')).toBeUndefined()
196
+ expect(findFile(off, '_models.ts')).toBeUndefined()
197
+
198
+ const onScope = findFile(on, 'messages.ts')!
199
+ const offScope = findFile(off, 'messages.ts')!
200
+ expect(onScope.code).toBe(offScope.code)
201
+ })
202
+
203
+ it('sharedTypesImport re-exports the model and skips generating its body', async () => {
204
+ const files = await runPipeline({
205
+ envelope: modelEnvelope(),
206
+ outDir: 'out',
207
+ dryRun: true,
208
+ shareModels: true,
209
+ namespaceTypes: true,
210
+ sharedTypesImport: { 'urn:msg': { module: '@shared/schemas', name: 'Message' } },
211
+ })
212
+
213
+ const modelsFile = findFile(files, '_models.ts')!
214
+ expect(modelsFile.code).toContain("export { Message } from '@shared/schemas'")
215
+ // No generated body when the model is externally imported.
216
+ expect(modelsFile.code).not.toMatch(/export type Message =/)
217
+
218
+ // Scopes still import Message from ./_models (the single hub).
219
+ const messages = findFile(files, 'messages.ts')!
220
+ expect(messages.code).toContain("from './_models'")
221
+ expect(messages.code).toContain('Message')
222
+ })
223
+
224
+ it('flat mode (namespaceTypes:false) imports Message from ./_models', async () => {
225
+ const files = await runPipeline({
226
+ envelope: modelEnvelope(),
227
+ outDir: 'out',
228
+ dryRun: true,
229
+ shareModels: true,
230
+ namespaceTypes: false,
231
+ })
232
+ const messages = findFile(files, 'messages.ts')!
233
+ expect(messages.code).toContain("import type { Message } from './_models'")
234
+ })
235
+
236
+ it('fails loudly when a shared model name collides with a property-derived sub-type', async () => {
237
+ // `latest` references the Message model; a sibling property literally named
238
+ // `message` would make ajsc extract a structural sub-type ALSO named
239
+ // `Message`. ajsc silently merges the two (the model reference resolves to
240
+ // the unrelated structural type), so codegen must reject this rather than
241
+ // emit a silently-wrong type. See assertNoModelNameCollision in emit-scope.
242
+ const collision = makeEnvelope([
243
+ makeApiRoute({
244
+ name: 'GetThread',
245
+ scope: 'chat',
246
+ fullPath: '/thread',
247
+ jsonSchema: {
248
+ res: {
249
+ body: {
250
+ type: 'object',
251
+ required: ['latest', 'message'],
252
+ properties: {
253
+ latest: {
254
+ type: 'object',
255
+ $id: 'urn:msg',
256
+ title: 'Message',
257
+ required: ['id'],
258
+ properties: { id: { type: 'string' } },
259
+ },
260
+ message: {
261
+ type: 'object',
262
+ required: ['unread'],
263
+ properties: { unread: { type: 'boolean' } },
264
+ },
265
+ },
266
+ },
267
+ },
268
+ },
269
+ }),
270
+ ])
271
+
272
+ await expect(
273
+ runPipeline({
274
+ envelope: collision,
275
+ outDir: 'out',
276
+ dryRun: true,
277
+ shareModels: true,
278
+ namespaceTypes: true,
279
+ selfContained: false,
280
+ }),
281
+ ).rejects.toThrow(/shared model 'Message' collides/)
282
+ })
283
+ })
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, afterEach } from 'vitest'
2
+ import { readFile, rm, mkdtemp } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { writeDocEnvelope } from './doc-envelope.js'
6
+ import type { DocEnvelope } from './implementations/types.js'
7
+
8
+ const envelope: DocEnvelope = { basePath: '', headers: [], errors: [], routes: [] }
9
+
10
+ describe('writeDocEnvelope', () => {
11
+ let dir: string
12
+ afterEach(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
13
+
14
+ it('writes a plain DocEnvelope as pretty JSON', async () => {
15
+ dir = await mkdtemp(join(tmpdir(), 'tsp-'))
16
+ const out = join(dir, 'nested', 'docs.json')
17
+ await writeDocEnvelope(envelope, out)
18
+ const parsed = JSON.parse(await readFile(out, 'utf8'))
19
+ expect(parsed).toEqual(envelope)
20
+ })
21
+
22
+ it('accepts a builder-like object with toDocEnvelope()', async () => {
23
+ dir = await mkdtemp(join(tmpdir(), 'tsp-'))
24
+ const out = join(dir, 'docs.json')
25
+ await writeDocEnvelope({ toDocEnvelope: () => envelope }, out)
26
+ expect(JSON.parse(await readFile(out, 'utf8'))).toEqual(envelope)
27
+ })
28
+
29
+ it('accepts a DocRegistry-like object (toJSON)', async () => {
30
+ dir = await mkdtemp(join(tmpdir(), 'tsp-'))
31
+ const out = join(dir, 'docs.json')
32
+ await writeDocEnvelope({ toJSON: () => envelope }, out)
33
+ expect(JSON.parse(await readFile(out, 'utf8'))).toEqual(envelope)
34
+ })
35
+ })
@@ -0,0 +1,30 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import type { DocEnvelope } from './implementations/types.js'
4
+
5
+ export type DocEnvelopeSource =
6
+ | DocEnvelope
7
+ | { toDocEnvelope(): DocEnvelope }
8
+ | { toJSON(): DocEnvelope }
9
+
10
+ function coerceToEnvelope(source: DocEnvelopeSource): DocEnvelope {
11
+ if (typeof (source as { toDocEnvelope?: unknown }).toDocEnvelope === 'function') {
12
+ return (source as { toDocEnvelope(): DocEnvelope }).toDocEnvelope()
13
+ }
14
+ if (typeof (source as { toJSON?: unknown }).toJSON === 'function') {
15
+ return (source as { toJSON(): DocEnvelope }).toJSON()
16
+ }
17
+ return source as DocEnvelope
18
+ }
19
+
20
+ /**
21
+ * Serializes a doc envelope to disk as pretty JSON so codegen can run offline
22
+ * via `--file <path>` without a running server. Accepts a plain `DocEnvelope`,
23
+ * a builder exposing `toDocEnvelope()`, or a `DocRegistry` exposing `toJSON()`.
24
+ * Parent directories are created as needed.
25
+ */
26
+ export async function writeDocEnvelope(source: DocEnvelopeSource, path: string): Promise<void> {
27
+ const envelope = coerceToEnvelope(source)
28
+ await mkdir(dirname(path), { recursive: true })
29
+ await writeFile(path, JSON.stringify(envelope, null, 2), 'utf8')
30
+ }
package/src/exports.ts CHANGED
@@ -7,3 +7,5 @@ export * from './schema/resolve-schema-lib.js'
7
7
  export * from './schema/types.js'
8
8
  export type { HttpReturn } from './create-http.js'
9
9
  export type { TCreateHttpConfig } from './types.js'
10
+ export { writeDocEnvelope, type DocEnvelopeSource } from './doc-envelope.js'
11
+ export type { DocEnvelope } from './implementations/types.js'