ts-procedures 8.3.0 → 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.
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -8
- package/agent_config/claude-code/skills/ts-procedures/templates/client.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/hono.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/procedure.md +3 -3
- package/agent_config/claude-code/skills/ts-procedures/templates/stream-procedure.md +3 -3
- package/build/client/call.js +1 -1
- package/build/client/call.js.map +1 -1
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +23 -1
- package/build/client/index.js.map +1 -1
- package/build/client/index.test.js +87 -0
- package/build/client/index.test.js.map +1 -1
- package/build/client/resolve-options.d.ts +5 -4
- package/build/client/resolve-options.js +18 -7
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +53 -24
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/stream.js +1 -1
- package/build/client/stream.js.map +1 -1
- package/build/client/types.d.ts +31 -3
- package/build/codegen/__fixtures__/make-envelope.d.ts +41 -0
- package/build/codegen/__fixtures__/make-envelope.js +38 -0
- package/build/codegen/__fixtures__/make-envelope.js.map +1 -0
- package/build/codegen/bin/cli.d.ts +11 -0
- package/build/codegen/bin/cli.js +30 -21
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +36 -1
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bin/flag-specs.d.ts +10 -0
- package/build/codegen/bin/flag-specs.js +60 -0
- package/build/codegen/bin/flag-specs.js.map +1 -0
- package/build/codegen/bin/flag-specs.test.d.ts +1 -0
- package/build/codegen/bin/flag-specs.test.js +26 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -0
- package/build/codegen/collect-models.d.ts +37 -0
- package/build/codegen/collect-models.js +74 -0
- package/build/codegen/collect-models.js.map +1 -0
- package/build/codegen/collect-models.test.d.ts +1 -0
- package/build/codegen/collect-models.test.js +40 -0
- package/build/codegen/collect-models.test.js.map +1 -0
- package/build/codegen/emit-client-runtime.js +1 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-models.d.ts +26 -0
- package/build/codegen/emit-models.js +53 -0
- package/build/codegen/emit-models.js.map +1 -0
- package/build/codegen/emit-models.test.d.ts +1 -0
- package/build/codegen/emit-models.test.js +42 -0
- package/build/codegen/emit-models.test.js.map +1 -0
- package/build/codegen/emit-scope.d.ts +10 -0
- package/build/codegen/emit-scope.js +119 -34
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-types.d.ts +26 -1
- package/build/codegen/emit-types.js +27 -5
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/index.d.ts +5 -0
- package/build/codegen/index.js +2 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/model-refs.d.ts +27 -0
- package/build/codegen/model-refs.js +49 -0
- package/build/codegen/model-refs.js.map +1 -0
- package/build/codegen/model-refs.test.d.ts +1 -0
- package/build/codegen/model-refs.test.js +33 -0
- package/build/codegen/model-refs.test.js.map +1 -0
- package/build/codegen/pipeline.d.ts +3 -0
- package/build/codegen/pipeline.js +3 -1
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/schema-walk.d.ts +13 -0
- package/build/codegen/schema-walk.js +26 -0
- package/build/codegen/schema-walk.js.map +1 -0
- package/build/codegen/schema-walk.test.d.ts +1 -0
- package/build/codegen/schema-walk.test.js +35 -0
- package/build/codegen/schema-walk.test.js.map +1 -0
- package/build/codegen/targets/_shared/target-run.d.ts +5 -0
- package/build/codegen/targets/ts/run.js +28 -1
- package/build/codegen/targets/ts/run.js.map +1 -1
- package/build/codegen/targets/ts/shared-models.test.d.ts +1 -0
- package/build/codegen/targets/ts/shared-models.test.js +258 -0
- package/build/codegen/targets/ts/shared-models.test.js.map +1 -0
- package/build/doc-envelope.d.ts +13 -0
- package/build/doc-envelope.js +23 -0
- package/build/doc-envelope.js.map +1 -0
- package/build/doc-envelope.test.d.ts +1 -0
- package/build/doc-envelope.test.js +31 -0
- package/build/doc-envelope.test.js.map +1 -0
- package/build/exports.d.ts +2 -0
- package/build/exports.js +1 -0
- package/build/exports.js.map +1 -1
- package/docs/client-and-codegen.md +101 -0
- package/docs/handoffs/ajsc-named-type-collision.md +134 -0
- package/docs/handoffs/ajsc-named-type-support.md +181 -0
- package/docs/superpowers/plans/2026-06-05-dx-feedback-round.md +1292 -0
- package/docs/superpowers/specs/2026-06-05-dx-feedback-round-design.md +285 -0
- package/package.json +2 -2
- package/src/client/call.ts +1 -1
- package/src/client/index.test.ts +98 -0
- package/src/client/index.ts +32 -1
- package/src/client/resolve-options.test.ts +73 -26
- package/src/client/resolve-options.ts +23 -9
- package/src/client/stream.ts +1 -1
- package/src/client/types.ts +34 -3
- package/src/codegen/__fixtures__/make-envelope.ts +89 -0
- package/src/codegen/bin/cli.test.ts +38 -1
- package/src/codegen/bin/cli.ts +33 -22
- package/src/codegen/bin/flag-specs.test.ts +27 -0
- package/src/codegen/bin/flag-specs.ts +69 -0
- package/src/codegen/collect-models.test.ts +46 -0
- package/src/codegen/collect-models.ts +108 -0
- package/src/codegen/emit-client-runtime.ts +1 -0
- package/src/codegen/emit-models.test.ts +48 -0
- package/src/codegen/emit-models.ts +63 -0
- package/src/codegen/emit-scope.ts +145 -33
- package/src/codegen/emit-types.ts +48 -7
- package/src/codegen/index.ts +7 -0
- package/src/codegen/model-refs.test.ts +37 -0
- package/src/codegen/model-refs.ts +57 -0
- package/src/codegen/pipeline.ts +6 -1
- package/src/codegen/schema-walk.test.ts +37 -0
- package/src/codegen/schema-walk.ts +23 -0
- package/src/codegen/targets/_shared/target-run.ts +5 -0
- package/src/codegen/targets/ts/run.ts +33 -0
- package/src/codegen/targets/ts/shared-models.test.ts +283 -0
- package/src/doc-envelope.test.ts +35 -0
- package/src/doc-envelope.ts +30 -0
- package/src/exports.ts +2 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AdapterRequest,
|
|
3
|
+
ClientHeadersInit,
|
|
3
4
|
ProcedureCallDefaults,
|
|
4
5
|
ProcedureCallOptions,
|
|
5
6
|
RequestMeta,
|
|
@@ -79,15 +80,28 @@ export function resolveSignal(
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
83
|
+
* Resolves a `ClientHeadersInit` value: a static record passes through, a
|
|
84
|
+
* function is invoked and (if async) awaited — re-evaluated on every call so
|
|
85
|
+
* values like a rotating bearer token never go stale.
|
|
84
86
|
*/
|
|
85
|
-
|
|
87
|
+
async function resolveHeadersValue(
|
|
88
|
+
h: ClientHeadersInit | undefined,
|
|
89
|
+
): Promise<Record<string, string> | undefined> {
|
|
90
|
+
if (h == null) return undefined
|
|
91
|
+
return typeof h === 'function' ? await h() : h
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Merges headers with precedence: default < per-call. Function-valued headers
|
|
96
|
+
* are evaluated (and awaited) per call. Returns undefined if no headers would
|
|
97
|
+
* be set.
|
|
98
|
+
*/
|
|
99
|
+
export async function resolveHeaders(
|
|
86
100
|
defaults: ProcedureCallDefaults | undefined,
|
|
87
101
|
options: ProcedureCallOptions | undefined,
|
|
88
|
-
): Record<string, string> | undefined {
|
|
89
|
-
const defaultHeaders = defaults?.headers
|
|
90
|
-
const callHeaders = options?.headers
|
|
102
|
+
): Promise<Record<string, string> | undefined> {
|
|
103
|
+
const defaultHeaders = await resolveHeadersValue(defaults?.headers)
|
|
104
|
+
const callHeaders = await resolveHeadersValue(options?.headers)
|
|
91
105
|
|
|
92
106
|
if (!defaultHeaders && !callHeaders) return undefined
|
|
93
107
|
|
|
@@ -137,13 +151,13 @@ export interface ApplyRequestOptionsResult {
|
|
|
137
151
|
* the error classifier in Task 6) can distinguish timeout from user abort without
|
|
138
152
|
* losing provenance after `AbortSignal.any` collapses the originals.
|
|
139
153
|
*/
|
|
140
|
-
export function applyRequestOptions(
|
|
154
|
+
export async function applyRequestOptions(
|
|
141
155
|
request: AdapterRequest,
|
|
142
156
|
defaults: ProcedureCallDefaults | undefined,
|
|
143
157
|
options: ProcedureCallOptions | undefined,
|
|
144
|
-
): ApplyRequestOptionsResult {
|
|
158
|
+
): Promise<ApplyRequestOptionsResult> {
|
|
145
159
|
const signalSources = resolveSignalSources(defaults, options)
|
|
146
|
-
const resolvedHeaders = resolveHeaders(defaults, options)
|
|
160
|
+
const resolvedHeaders = await resolveHeaders(defaults, options)
|
|
147
161
|
const meta = resolveMeta(defaults, options)
|
|
148
162
|
|
|
149
163
|
const headers =
|
package/src/client/stream.ts
CHANGED
|
@@ -186,7 +186,7 @@ export async function executeStream<TYield, TReturn = void>(
|
|
|
186
186
|
let request = buildAdapterRequest(descriptor, resolvedBasePath)
|
|
187
187
|
|
|
188
188
|
// 2. Apply request-level options (headers, signal, timeout, meta)
|
|
189
|
-
const applied = applyRequestOptions(request, defaults, options)
|
|
189
|
+
const applied = await applyRequestOptions(request, defaults, options)
|
|
190
190
|
request = applied.request
|
|
191
191
|
const signalSources = applied.signalSources
|
|
192
192
|
|
package/src/client/types.ts
CHANGED
|
@@ -193,16 +193,32 @@ export interface TypedStream<TYield, TReturn = void> extends AsyncIterable<TYiel
|
|
|
193
193
|
* signal are provided, they're combined — whichever aborts first wins.
|
|
194
194
|
* - `timeout`: Timeout in milliseconds. Combined with `signal` the same way.
|
|
195
195
|
* A per-call `timeout: 0` disables an inherited default timeout.
|
|
196
|
-
* - `headers`: Extra headers merged into the request
|
|
197
|
-
*
|
|
196
|
+
* - `headers`: Extra headers merged into the request, as a static record OR a
|
|
197
|
+
* (possibly async) function evaluated per request. Per-call keys win over
|
|
198
|
+
* default keys. Use the function form for values that change between calls
|
|
199
|
+
* (e.g. a rotating bearer token) so they don't go stale. Still subject to
|
|
200
|
+
* further mutation by `onBeforeRequest` hooks.
|
|
198
201
|
* - `basePath`: Override the base path for this call. Per-call > default > config.
|
|
199
202
|
* - `meta`: Per-request metadata typed via the {@link RequestMeta} interface.
|
|
200
203
|
* Merged shallowly (per-call keys win over default keys).
|
|
201
204
|
*/
|
|
205
|
+
/**
|
|
206
|
+
* Request headers as a static record OR a (possibly async) function evaluated
|
|
207
|
+
* per request. Use the function form for values that change between calls — e.g.
|
|
208
|
+
* a rotating bearer token: `headers: () => ({ Authorization: `Bearer ${session.token}` })`.
|
|
209
|
+
* A static record captured at construction goes stale; a function is re-evaluated each call.
|
|
210
|
+
*
|
|
211
|
+
* Named `ClientHeadersInit` (not `HeadersInit`) to avoid shadowing the DOM lib's
|
|
212
|
+
* global `HeadersInit`, which has a different shape.
|
|
213
|
+
*/
|
|
214
|
+
export type ClientHeadersInit =
|
|
215
|
+
| Record<string, string>
|
|
216
|
+
| (() => Record<string, string> | Promise<Record<string, string>>)
|
|
217
|
+
|
|
202
218
|
export interface ProcedureCallDefaults {
|
|
203
219
|
signal?: AbortSignal
|
|
204
220
|
timeout?: number
|
|
205
|
-
headers?:
|
|
221
|
+
headers?: ClientHeadersInit
|
|
206
222
|
basePath?: string
|
|
207
223
|
meta?: RequestMeta
|
|
208
224
|
}
|
|
@@ -302,6 +318,21 @@ export interface CreateClientConfig<TScopes> {
|
|
|
302
318
|
* matches, falls back to `ClientHttpError` (transport error shape).
|
|
303
319
|
*/
|
|
304
320
|
errorRegistry?: ErrorRegistry
|
|
321
|
+
/**
|
|
322
|
+
* Returns the current bearer token, re-evaluated per request (so a rotating
|
|
323
|
+
* token never goes stale). Wired internally to the `Authorization: Bearer
|
|
324
|
+
* <token>` header — a `null`/`undefined` token omits the header entirely.
|
|
325
|
+
*
|
|
326
|
+
* This is sugar over a function-valued `defaults.headers`; it composes with
|
|
327
|
+
* `defaults.headers` (both are applied, auth last) and is still subject to
|
|
328
|
+
* per-call `headers` and `onBeforeRequest`, which run after and can override.
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* ```ts
|
|
332
|
+
* createClient({ adapter, basePath, auth: () => session.token, scopes })
|
|
333
|
+
* ```
|
|
334
|
+
*/
|
|
335
|
+
auth?: () => string | null | undefined | Promise<string | null | undefined>
|
|
305
336
|
}
|
|
306
337
|
|
|
307
338
|
// ── Result Types ─────────────────────────────────────────
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
APIHttpRouteDoc,
|
|
3
|
+
DocEnvelope,
|
|
4
|
+
ErrorDoc,
|
|
5
|
+
HeaderDoc,
|
|
6
|
+
HttpMethod,
|
|
7
|
+
RPCHttpRouteDoc,
|
|
8
|
+
} from '../../implementations/types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Typed builders for codegen test envelopes.
|
|
12
|
+
*
|
|
13
|
+
* These return real {@link DocEnvelope}/route-doc shapes with no `as unknown as`
|
|
14
|
+
* casts — the JSON Schema slots are `Record<string, unknown>`, so any plain
|
|
15
|
+
* schema object drops in directly. Callers pass small typed param objects and
|
|
16
|
+
* the builders fill in sensible defaults (method, empty `errors`, etc.).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface MakeApiRouteOptions {
|
|
20
|
+
name: string
|
|
21
|
+
scope?: string
|
|
22
|
+
method?: HttpMethod
|
|
23
|
+
/** Full resolved path. Defaults to `/${name}` when omitted. */
|
|
24
|
+
fullPath?: string
|
|
25
|
+
/** Route path (without pathPrefix). Defaults to `fullPath`. */
|
|
26
|
+
path?: string
|
|
27
|
+
successStatus?: number
|
|
28
|
+
errors?: string[]
|
|
29
|
+
jsonSchema?: APIHttpRouteDoc['jsonSchema']
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build a typed `kind: 'api'` route doc. */
|
|
33
|
+
export function makeApiRoute(opts: MakeApiRouteOptions): APIHttpRouteDoc {
|
|
34
|
+
const fullPath = opts.fullPath ?? `/${opts.name}`
|
|
35
|
+
return {
|
|
36
|
+
kind: 'api',
|
|
37
|
+
name: opts.name,
|
|
38
|
+
scope: opts.scope,
|
|
39
|
+
method: opts.method ?? 'get',
|
|
40
|
+
path: opts.path ?? fullPath,
|
|
41
|
+
fullPath,
|
|
42
|
+
successStatus: opts.successStatus,
|
|
43
|
+
errors: opts.errors ?? [],
|
|
44
|
+
jsonSchema: opts.jsonSchema ?? {},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MakeRpcRouteOptions {
|
|
49
|
+
name: string
|
|
50
|
+
scope?: string | string[]
|
|
51
|
+
version?: number
|
|
52
|
+
/** Route path. Defaults to `/${name}` when omitted. */
|
|
53
|
+
path?: string
|
|
54
|
+
errors?: string[]
|
|
55
|
+
jsonSchema?: RPCHttpRouteDoc['jsonSchema']
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Build a typed `kind: 'rpc'` route doc. */
|
|
59
|
+
export function makeRpcRoute(opts: MakeRpcRouteOptions): RPCHttpRouteDoc {
|
|
60
|
+
return {
|
|
61
|
+
kind: 'rpc',
|
|
62
|
+
name: opts.name,
|
|
63
|
+
scope: opts.scope ?? 'default',
|
|
64
|
+
version: opts.version ?? 1,
|
|
65
|
+
path: opts.path ?? `/${opts.name}`,
|
|
66
|
+
method: 'post',
|
|
67
|
+
errors: opts.errors ?? [],
|
|
68
|
+
jsonSchema: opts.jsonSchema ?? {},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MakeEnvelopeOptions {
|
|
73
|
+
basePath?: string
|
|
74
|
+
headers?: HeaderDoc[]
|
|
75
|
+
errors?: ErrorDoc[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Wrap route docs in a typed {@link DocEnvelope} with sensible defaults. */
|
|
79
|
+
export function makeEnvelope(
|
|
80
|
+
routes: DocEnvelope['routes'],
|
|
81
|
+
opts: MakeEnvelopeOptions = {},
|
|
82
|
+
): DocEnvelope {
|
|
83
|
+
return {
|
|
84
|
+
basePath: opts.basePath ?? '/api',
|
|
85
|
+
headers: opts.headers ?? [],
|
|
86
|
+
errors: opts.errors ?? [],
|
|
87
|
+
routes,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { vi } from 'vitest'
|
|
3
|
-
import { parseArgs, loadConfigFile, extractConfigPath, printPostRunHints, warnIfKotlinNoOpFlags, type CodegenConfig } from './cli.js'
|
|
3
|
+
import { parseArgs, loadConfigFile, extractConfigPath, printPostRunHints, warnIfKotlinNoOpFlags, shouldShowHelp, type CodegenConfig } from './cli.js'
|
|
4
|
+
import { formatHelp } from './flag-specs.js'
|
|
4
5
|
|
|
5
6
|
describe('parseArgs', () => {
|
|
6
7
|
it('parses --url and --out', () => {
|
|
@@ -432,6 +433,23 @@ describe('cli — unsupported-unions flag', () => {
|
|
|
432
433
|
})
|
|
433
434
|
})
|
|
434
435
|
|
|
436
|
+
describe('--help handling', () => {
|
|
437
|
+
it('shouldShowHelp true for --help, -h, and bare (no args)', () => {
|
|
438
|
+
expect(shouldShowHelp(['--help'])).toBe(true)
|
|
439
|
+
expect(shouldShowHelp(['-h'])).toBe(true)
|
|
440
|
+
expect(shouldShowHelp([])).toBe(true)
|
|
441
|
+
})
|
|
442
|
+
it('shouldShowHelp false when real flags are present', () => {
|
|
443
|
+
expect(shouldShowHelp(['--url', 'http://x', '--out', 'gen'])).toBe(false)
|
|
444
|
+
})
|
|
445
|
+
it('help text covers the full flag surface', () => {
|
|
446
|
+
const help = formatHelp()
|
|
447
|
+
expect(help).toContain('--watch')
|
|
448
|
+
expect(help).toContain('--enum-style')
|
|
449
|
+
expect(help).toContain('--target')
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
435
453
|
describe('cli — printPostRunHints', () => {
|
|
436
454
|
it('prints a setup-guide pointer for the kotlin target', () => {
|
|
437
455
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
@@ -518,3 +536,22 @@ describe('cli — warnIfKotlinNoOpFlags', () => {
|
|
|
518
536
|
}
|
|
519
537
|
})
|
|
520
538
|
})
|
|
539
|
+
|
|
540
|
+
describe('--share-models', () => {
|
|
541
|
+
it('defaults shareModels to true', () => {
|
|
542
|
+
expect(parseArgs(['--out', 'g', '--url', 'u']).shareModels).toBe(true)
|
|
543
|
+
})
|
|
544
|
+
it('--no-share-models sets it false', () => {
|
|
545
|
+
expect(parseArgs(['--out', 'g', '--url', 'u', '--no-share-models']).shareModels).toBe(false)
|
|
546
|
+
})
|
|
547
|
+
it('--share-models sets it true (overriding a config false)', () => {
|
|
548
|
+
expect(parseArgs(['--out', 'g', '--url', 'u', '--share-models'], { shareModels: false } as CodegenConfig).shareModels).toBe(true)
|
|
549
|
+
})
|
|
550
|
+
it('reads shareModels:false from config when no flag given', () => {
|
|
551
|
+
expect(parseArgs(['--out', 'g', '--url', 'u'], { shareModels: false } as CodegenConfig).shareModels).toBe(false)
|
|
552
|
+
})
|
|
553
|
+
it('reads sharedTypesImport from config', () => {
|
|
554
|
+
const cfg = { sharedTypesImport: { 'urn:msg': { module: '@shared/schemas', name: 'Message' } } }
|
|
555
|
+
expect(parseArgs(['--out', 'g', '--url', 'u'], cfg as CodegenConfig).sharedTypesImport).toEqual(cfg.sharedTypesImport)
|
|
556
|
+
})
|
|
557
|
+
})
|
package/src/codegen/bin/cli.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { resolve } from 'node:path'
|
|
|
4
4
|
import { createHash } from 'node:crypto'
|
|
5
5
|
import { generateClient, type GenerateClientOptions } from '../index.js'
|
|
6
6
|
import type { AjscOptions } from '../emit-types.js'
|
|
7
|
+
import type { SharedTypesImportMap } from '../collect-models.js'
|
|
8
|
+
import { KNOWN_FLAGS, formatHelp } from './flag-specs.js'
|
|
7
9
|
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Types
|
|
@@ -26,6 +28,8 @@ export interface CodegenConfig {
|
|
|
26
28
|
kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
|
|
27
29
|
swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
|
|
28
30
|
unsupportedUnions?: 'throw' | 'fallback'
|
|
31
|
+
shareModels?: boolean
|
|
32
|
+
sharedTypesImport?: SharedTypesImportMap
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export interface ParsedArgs {
|
|
@@ -45,6 +49,8 @@ export interface ParsedArgs {
|
|
|
45
49
|
kotlin?: { package: string; serializer?: 'kotlinx' | 'none' }
|
|
46
50
|
swift?: { serializer?: 'codable' | 'none'; accessLevel?: 'public' | 'internal' }
|
|
47
51
|
unsupportedUnions?: 'throw' | 'fallback'
|
|
52
|
+
shareModels: boolean
|
|
53
|
+
sharedTypesImport?: SharedTypesImportMap
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
// ---------------------------------------------------------------------------
|
|
@@ -73,30 +79,9 @@ export async function loadConfigFile(configPath?: string): Promise<CodegenConfig
|
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
// ---------------------------------------------------------------------------
|
|
76
|
-
//
|
|
82
|
+
// did-you-mean (the flag catalog now lives in ./flag-specs.ts)
|
|
77
83
|
// ---------------------------------------------------------------------------
|
|
78
84
|
|
|
79
|
-
/**
|
|
80
|
-
* Every flag `parseArgs` recognises. Kept in one place so the unknown-flag
|
|
81
|
-
* error can suggest the closest match for typos like `--targt` → `--target`.
|
|
82
|
-
*
|
|
83
|
-
* The list is also load-bearing: any new branch added to the parse loop
|
|
84
|
-
* MUST also be added here, or the loop will throw on the very flag it just
|
|
85
|
-
* accepted. (Kept literal — small enough that DRY-ing it isn't worth it.)
|
|
86
|
-
*/
|
|
87
|
-
const KNOWN_FLAGS = [
|
|
88
|
-
'--url', '--file', '--out', '--watch', '--interval',
|
|
89
|
-
'--enum-style', '--depluralize', '--array-item-naming', '--uncountable-words',
|
|
90
|
-
'--jsdoc', '--no-jsdoc',
|
|
91
|
-
'--client-import-path', '--dry-run',
|
|
92
|
-
'--namespace-types', '--no-namespace-types',
|
|
93
|
-
'--self-contained', '--no-self-contained',
|
|
94
|
-
'--service-name', '--clean-out-dir', '--no-clean-out-dir',
|
|
95
|
-
'--target', '--kotlin-package', '--kotlin-serializer',
|
|
96
|
-
'--swift-serializer', '--swift-access-level',
|
|
97
|
-
'--unsupported-unions', '--config',
|
|
98
|
-
] as const
|
|
99
|
-
|
|
100
85
|
/**
|
|
101
86
|
* Levenshtein distance between two strings — small ad-hoc implementation
|
|
102
87
|
* tuned for short flag names. We don't pull a dep just for typo suggestions.
|
|
@@ -183,6 +168,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
183
168
|
let swiftSerializer: 'codable' | 'none' | undefined = config?.swift?.serializer
|
|
184
169
|
let swiftAccessLevel: 'public' | 'internal' | undefined = config?.swift?.accessLevel
|
|
185
170
|
let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
|
|
171
|
+
let shareModels = config?.shareModels ?? true
|
|
172
|
+
const sharedTypesImport = config?.sharedTypesImport
|
|
186
173
|
let configPath: string | undefined
|
|
187
174
|
|
|
188
175
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -269,6 +256,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
269
256
|
} else {
|
|
270
257
|
throw new Error(`Invalid --unsupported-unions value: ${val ?? '(missing)'} (expected 'throw' or 'fallback')`)
|
|
271
258
|
}
|
|
259
|
+
} else if (arg === '--share-models') {
|
|
260
|
+
shareModels = true
|
|
261
|
+
} else if (arg === '--no-share-models') {
|
|
262
|
+
shareModels = false
|
|
272
263
|
} else if (arg === '--config') {
|
|
273
264
|
configPath = argv[++i]
|
|
274
265
|
} else if (arg !== undefined && arg.startsWith('--')) {
|
|
@@ -343,6 +334,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
343
334
|
}
|
|
344
335
|
: {}),
|
|
345
336
|
...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
|
|
337
|
+
shareModels,
|
|
338
|
+
...(sharedTypesImport !== undefined ? { sharedTypesImport } : {}),
|
|
346
339
|
}
|
|
347
340
|
}
|
|
348
341
|
|
|
@@ -430,6 +423,8 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
|
|
|
430
423
|
...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
|
|
431
424
|
...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
|
|
432
425
|
...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
|
|
426
|
+
shareModels: parsed.shareModels,
|
|
427
|
+
...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
|
|
433
428
|
...kotlinWiring,
|
|
434
429
|
...swiftWiring,
|
|
435
430
|
})
|
|
@@ -488,8 +483,22 @@ export function warnIfKotlinNoOpFlags(parsed: {
|
|
|
488
483
|
}
|
|
489
484
|
}
|
|
490
485
|
|
|
486
|
+
/**
|
|
487
|
+
* True when the CLI should print usage and exit 0: an explicit --help/-h, or a
|
|
488
|
+
* bare invocation with no args. --help is handled here (not in parseArgs) so it
|
|
489
|
+
* never reaches the unknown-flag branch and never appears in did-you-mean.
|
|
490
|
+
*/
|
|
491
|
+
export function shouldShowHelp(argv: string[]): boolean {
|
|
492
|
+
if (argv.length === 0) return true
|
|
493
|
+
return argv.includes('--help') || argv.includes('-h')
|
|
494
|
+
}
|
|
495
|
+
|
|
491
496
|
async function main(): Promise<void> {
|
|
492
497
|
const argv = process.argv.slice(2)
|
|
498
|
+
if (shouldShowHelp(argv)) {
|
|
499
|
+
console.log(formatHelp())
|
|
500
|
+
process.exit(0)
|
|
501
|
+
}
|
|
493
502
|
const configPath = extractConfigPath(argv)
|
|
494
503
|
const config = await loadConfigFile(configPath)
|
|
495
504
|
if (config != null) {
|
|
@@ -544,6 +553,8 @@ async function main(): Promise<void> {
|
|
|
544
553
|
...(parsed.unsupportedUnions !== undefined ? { unsupportedUnions: parsed.unsupportedUnions } : {}),
|
|
545
554
|
...(parsed.swift?.serializer !== undefined ? { swiftSerializer: parsed.swift.serializer } : {}),
|
|
546
555
|
...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
|
|
556
|
+
shareModels: parsed.shareModels,
|
|
557
|
+
...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
|
|
547
558
|
...kotlinWiring,
|
|
548
559
|
...swiftWiring,
|
|
549
560
|
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { FLAG_SPECS, KNOWN_FLAGS, formatHelp } from './flag-specs.js'
|
|
3
|
+
|
|
4
|
+
describe('flag-specs', () => {
|
|
5
|
+
it('derives KNOWN_FLAGS from the spec table', () => {
|
|
6
|
+
expect(KNOWN_FLAGS).toContain('--url')
|
|
7
|
+
expect(KNOWN_FLAGS).toContain('--service-name')
|
|
8
|
+
expect(KNOWN_FLAGS).toContain('--target')
|
|
9
|
+
for (const spec of FLAG_SPECS) expect(KNOWN_FLAGS).toContain(spec.name)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('formatHelp lists every flag with its description, grouped', () => {
|
|
13
|
+
const help = formatHelp()
|
|
14
|
+
expect(help).toMatch(/Usage: ts-procedures-codegen/)
|
|
15
|
+
for (const spec of FLAG_SPECS) {
|
|
16
|
+
expect(help).toContain(spec.name)
|
|
17
|
+
expect(help).toContain(spec.description)
|
|
18
|
+
}
|
|
19
|
+
expect(help).toMatch(/Source/)
|
|
20
|
+
expect(help).toMatch(/Targets/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('does not list --help/-h as a real codegen flag in KNOWN_FLAGS', () => {
|
|
24
|
+
expect(KNOWN_FLAGS).not.toContain('--help')
|
|
25
|
+
expect(KNOWN_FLAGS).not.toContain('-h')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export interface FlagSpec {
|
|
2
|
+
name: string
|
|
3
|
+
arg?: string
|
|
4
|
+
description: string
|
|
5
|
+
group: 'Source' | 'Output' | 'Codegen' | 'Targets' | 'Misc'
|
|
6
|
+
default?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const FLAG_SPECS: readonly FlagSpec[] = [
|
|
10
|
+
// Source
|
|
11
|
+
{ name: '--url', arg: '<url>', description: 'Fetch the doc envelope from a running server', group: 'Source' },
|
|
12
|
+
{ name: '--file', arg: '<path>', description: 'Read the doc envelope from a JSON file (see writeDocEnvelope)', group: 'Source' },
|
|
13
|
+
{ name: '--config', arg: '<path>', description: 'Load options from a JSON config file', group: 'Source' },
|
|
14
|
+
// Output
|
|
15
|
+
{ name: '--out', arg: '<dir>', description: 'Output directory for generated files', group: 'Output' },
|
|
16
|
+
{ name: '--dry-run', description: 'Print what would be written without touching disk', group: 'Output' },
|
|
17
|
+
{ name: '--clean-out-dir', description: 'Prune orphaned generator-owned files before writing', group: 'Output', default: 'on' },
|
|
18
|
+
{ name: '--no-clean-out-dir', description: 'Disable orphan pruning', group: 'Output' },
|
|
19
|
+
{ name: '--watch', description: 'Regenerate on envelope change', group: 'Output' },
|
|
20
|
+
{ name: '--interval', arg: '<ms>', description: 'Poll interval for --watch (default 3000)', group: 'Output' },
|
|
21
|
+
// Codegen
|
|
22
|
+
{ name: '--service-name', arg: '<name>', description: 'Service identifier driving generated names', group: 'Codegen', default: 'Api' },
|
|
23
|
+
{ name: '--namespace-types', description: 'Wrap types in nested namespaces', group: 'Codegen', default: 'on' },
|
|
24
|
+
{ name: '--no-namespace-types', description: 'Flat type names', group: 'Codegen' },
|
|
25
|
+
{ name: '--self-contained', description: 'Bundle the client runtime into the output dir', group: 'Codegen', default: 'on' },
|
|
26
|
+
{ name: '--no-self-contained', description: 'Import the client runtime from ts-procedures/client', group: 'Codegen' },
|
|
27
|
+
{ name: '--client-import-path', arg: '<path>', description: 'Override the client runtime import path', group: 'Codegen' },
|
|
28
|
+
{ name: '--share-models', description: 'Hoist $id-bearing schemas into a shared _models.ts', group: 'Codegen', default: 'on' },
|
|
29
|
+
{ name: '--no-share-models', description: 'Inline every type per route (legacy behaviour)', group: 'Codegen' },
|
|
30
|
+
{ name: '--jsdoc', description: 'Emit JSDoc on generated types', group: 'Codegen', default: 'on' },
|
|
31
|
+
{ name: '--no-jsdoc', description: 'Suppress JSDoc', group: 'Codegen' },
|
|
32
|
+
{ name: '--enum-style', arg: '<union|enum>', description: 'How to emit enums (namespace mode)', group: 'Codegen' },
|
|
33
|
+
{ name: '--depluralize', description: 'Depluralize extracted array-item type names', group: 'Codegen' },
|
|
34
|
+
{ name: '--array-item-naming', arg: '<name|false>', description: 'Naming for extracted array-item types', group: 'Codegen' },
|
|
35
|
+
{ name: '--uncountable-words', arg: '<csv>', description: 'Words exempt from depluralization', group: 'Codegen' },
|
|
36
|
+
// Targets
|
|
37
|
+
{ name: '--target', arg: '<ts|kotlin|swift>', description: 'Output language', group: 'Targets', default: 'ts' },
|
|
38
|
+
{ name: '--kotlin-package', arg: '<pkg>', description: 'Package for Kotlin output (required for --target kotlin)', group: 'Targets' },
|
|
39
|
+
{ name: '--kotlin-serializer', arg: '<kotlinx|none>', description: 'Kotlin serialization annotations', group: 'Targets', default: 'kotlinx' },
|
|
40
|
+
{ name: '--swift-serializer', arg: '<codable|none>', description: 'Swift Codable conformance', group: 'Targets', default: 'codable' },
|
|
41
|
+
{ name: '--swift-access-level', arg: '<public|internal>', description: 'Swift access level', group: 'Targets', default: 'public' },
|
|
42
|
+
{ name: '--unsupported-unions', arg: '<throw|fallback>', description: 'Behaviour for untagged oneOf schemas', group: 'Targets', default: 'throw' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export const KNOWN_FLAGS: readonly string[] = FLAG_SPECS.map((s) => s.name)
|
|
46
|
+
|
|
47
|
+
const GROUP_ORDER: FlagSpec['group'][] = ['Source', 'Output', 'Codegen', 'Targets', 'Misc']
|
|
48
|
+
|
|
49
|
+
export function formatHelp(): string {
|
|
50
|
+
const lines: string[] = []
|
|
51
|
+
lines.push('Usage: ts-procedures-codegen --out <dir> (--url <url> | --file <path>) [options]')
|
|
52
|
+
lines.push('')
|
|
53
|
+
lines.push('Generate a typed client from a ts-procedures doc envelope.')
|
|
54
|
+
lines.push('')
|
|
55
|
+
const col = Math.max(...FLAG_SPECS.map((s) => (s.name + (s.arg ? ' ' + s.arg : '')).length)) + 2
|
|
56
|
+
for (const group of GROUP_ORDER) {
|
|
57
|
+
const specs = FLAG_SPECS.filter((s) => s.group === group)
|
|
58
|
+
if (specs.length === 0) continue
|
|
59
|
+
lines.push(`${group}:`)
|
|
60
|
+
for (const s of specs) {
|
|
61
|
+
const left = s.name + (s.arg ? ' ' + s.arg : '')
|
|
62
|
+
const def = s.default ? ` (default: ${s.default})` : ''
|
|
63
|
+
lines.push(` ${left.padEnd(col)}${s.description}${def}`)
|
|
64
|
+
}
|
|
65
|
+
lines.push('')
|
|
66
|
+
}
|
|
67
|
+
lines.push(' -h, --help Show this help and exit')
|
|
68
|
+
return lines.join('\n')
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { it, expect } from 'vitest'
|
|
2
|
+
import { collectModels, resolveModelImports } from './collect-models.js'
|
|
3
|
+
|
|
4
|
+
const message = { type: 'object', $id: 'urn:msg', title: 'Message', properties: { id: { type: 'string' } } }
|
|
5
|
+
|
|
6
|
+
it('collects each $id subschema once, named from title', () => {
|
|
7
|
+
const routes = [
|
|
8
|
+
{ jsonSchema: { response: message } },
|
|
9
|
+
{ jsonSchema: { body: { type: 'object', properties: { msg: message } } } },
|
|
10
|
+
] as any
|
|
11
|
+
const models = collectModels(routes)
|
|
12
|
+
expect(models.map((m) => m.name)).toEqual(['Message'])
|
|
13
|
+
expect(models[0]?.id).toBe('urn:msg')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('throws on $id collision with divergent body', () => {
|
|
17
|
+
const a = { type: 'object', $id: 'urn:x', title: 'X', properties: { a: { type: 'string' } } }
|
|
18
|
+
const b = { type: 'object', $id: 'urn:x', title: 'X', properties: { b: { type: 'number' } } }
|
|
19
|
+
expect(() => collectModels([{ jsonSchema: { response: a } }, { jsonSchema: { body: b } }] as any)).toThrow(/urn:x/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('disambiguates distinct $ids that derive the same name', () => {
|
|
23
|
+
const m1 = { type: 'object', $id: 'urn:a/message', title: 'Message', properties: { a: { type: 'string' } } }
|
|
24
|
+
const m2 = { type: 'object', $id: 'urn:b/message', title: 'Message', properties: { b: { type: 'string' } } }
|
|
25
|
+
const models = collectModels([{ jsonSchema: { response: m1 } }, { jsonSchema: { body: m2 } }] as any)
|
|
26
|
+
expect(models.map((m) => m.name).sort()).toEqual(['Message', 'Message2'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('ignores schemas without $id', () => {
|
|
30
|
+
const routes = [{ jsonSchema: { response: { type: 'object', title: 'Loose', properties: {} } } }] as any
|
|
31
|
+
expect(collectModels(routes)).toEqual([])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('does NOT throw for ordinary schemas without the reserved prefix', () => {
|
|
35
|
+
const routes = [
|
|
36
|
+
{ jsonSchema: { response: { type: 'object', properties: { kind: { const: 'message' }, status: { enum: ['ok', 'error'] } } } } },
|
|
37
|
+
] as any
|
|
38
|
+
expect(() => collectModels(routes)).not.toThrow()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('resolveModelImports tags mapped models and leaves others generated', () => {
|
|
42
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
43
|
+
const mapped = resolveModelImports(models, { 'urn:msg': { module: '@shared/schemas', name: 'Message' } })
|
|
44
|
+
expect(mapped[0]?.import).toEqual({ module: '@shared/schemas', name: 'Message' })
|
|
45
|
+
expect(resolveModelImports(models, {})[0]?.import).toBeUndefined()
|
|
46
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AnyHttpRouteDoc } from '../implementations/types.js'
|
|
2
|
+
import { toPascalCase } from './naming.js'
|
|
3
|
+
import { walkSubschemas } from './schema-walk.js'
|
|
4
|
+
|
|
5
|
+
/** A schema hoisted out of per-route inlining because it carries a `$id`. */
|
|
6
|
+
export interface CollectedModel {
|
|
7
|
+
/** The schema's `$id` — the stable identity key used for dedup and import mapping. */
|
|
8
|
+
id: string
|
|
9
|
+
/** PascalCase TypeScript identifier for the emitted model (disambiguated on collision). */
|
|
10
|
+
name: string
|
|
11
|
+
/** The model's JSON Schema (first-seen copy). */
|
|
12
|
+
schema: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Maps a model `$id` to an external import (module + exported name). */
|
|
16
|
+
export interface SharedTypeImport {
|
|
17
|
+
module: string
|
|
18
|
+
name: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `$id` → external import. Models with a matching entry are imported rather than generated. */
|
|
22
|
+
export type SharedTypesImportMap = Record<string, SharedTypeImport>
|
|
23
|
+
|
|
24
|
+
/** A {@link CollectedModel} tagged with its external import, if one was configured. */
|
|
25
|
+
export interface ResolvedModel extends CollectedModel {
|
|
26
|
+
import?: SharedTypeImport
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Produces a stable structural key for collision detection (key order does not matter). */
|
|
30
|
+
function structuralKey(value: unknown): string {
|
|
31
|
+
return JSON.stringify(value, (_k, v) => {
|
|
32
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
33
|
+
const sorted: Record<string, unknown> = {}
|
|
34
|
+
for (const k of Object.keys(v as Record<string, unknown>).sort()) {
|
|
35
|
+
sorted[k] = (v as Record<string, unknown>)[k]
|
|
36
|
+
}
|
|
37
|
+
return sorted
|
|
38
|
+
}
|
|
39
|
+
return v
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Derives a base PascalCase name from a schema's `title`, falling back to the last `$id` segment. */
|
|
44
|
+
function deriveBaseName(schema: Record<string, unknown>, id: string): string {
|
|
45
|
+
const title = typeof schema.title === 'string' ? schema.title : undefined
|
|
46
|
+
const source = title ?? id.split(/[/:#?]+/).filter(Boolean).pop() ?? id
|
|
47
|
+
const pascal = toPascalCase(source)
|
|
48
|
+
return pascal.length > 0 ? pascal : 'Model'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deep-walks every route's `jsonSchema` and collects each subschema carrying a
|
|
53
|
+
* string `$id` exactly once (deduped by `$id`, first-seen order preserved).
|
|
54
|
+
*
|
|
55
|
+
* - Names are derived from `title` (or the last `$id` segment), PascalCased.
|
|
56
|
+
* - Distinct `$id`s that derive the same name are disambiguated deterministically
|
|
57
|
+
* by first-seen order (`Message`, `Message2`, `Message3`, …).
|
|
58
|
+
* - Two nodes sharing an `$id` but differing structurally throw (the `$id` is a
|
|
59
|
+
* broken identity contract).
|
|
60
|
+
*/
|
|
61
|
+
export function collectModels(routes: AnyHttpRouteDoc[]): CollectedModel[] {
|
|
62
|
+
const byId = new Map<string, { schema: Record<string, unknown>; key: string }>()
|
|
63
|
+
const order: string[] = []
|
|
64
|
+
|
|
65
|
+
const visit = (obj: Record<string, unknown>): void => {
|
|
66
|
+
if (typeof obj.$id === 'string') {
|
|
67
|
+
const id = obj.$id
|
|
68
|
+
const key = structuralKey(obj)
|
|
69
|
+
const existing = byId.get(id)
|
|
70
|
+
if (existing) {
|
|
71
|
+
if (existing.key !== key) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[ts-procedures-codegen] Conflicting schemas share $id "${id}". A $id must identify a single structural shape; found two divergent definitions.`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
byId.set(id, { schema: obj, key })
|
|
78
|
+
order.push(id)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const route of routes) walkSubschemas(route.jsonSchema, visit)
|
|
84
|
+
|
|
85
|
+
const usedNames = new Map<string, number>()
|
|
86
|
+
return order.map((id) => {
|
|
87
|
+
const { schema } = byId.get(id)!
|
|
88
|
+
const base = deriveBaseName(schema, id)
|
|
89
|
+
const seen = usedNames.get(base) ?? 0
|
|
90
|
+
usedNames.set(base, seen + 1)
|
|
91
|
+
const name = seen === 0 ? base : `${base}${seen + 1}`
|
|
92
|
+
return { id, name, schema }
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Tags each collected model with its external import when its `$id` is a key in
|
|
98
|
+
* the import map; models without a mapping keep `import` undefined (generated locally).
|
|
99
|
+
*/
|
|
100
|
+
export function resolveModelImports(
|
|
101
|
+
models: CollectedModel[],
|
|
102
|
+
map: SharedTypesImportMap = {}
|
|
103
|
+
): ResolvedModel[] {
|
|
104
|
+
return models.map((model) => {
|
|
105
|
+
const mapped = map[model.id]
|
|
106
|
+
return mapped ? { ...model, import: mapped } : { ...model }
|
|
107
|
+
})
|
|
108
|
+
}
|