ts-procedures 8.4.0 → 8.6.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 (60) hide show
  1. package/build/codegen/bin/cli.d.ts +4 -0
  2. package/build/codegen/bin/cli.js +16 -0
  3. package/build/codegen/bin/cli.js.map +1 -1
  4. package/build/codegen/bin/cli.test.js +18 -0
  5. package/build/codegen/bin/cli.test.js.map +1 -1
  6. package/build/codegen/bin/flag-specs.js +2 -0
  7. package/build/codegen/bin/flag-specs.js.map +1 -1
  8. package/build/codegen/bin/flag-specs.test.js +9 -0
  9. package/build/codegen/bin/flag-specs.test.js.map +1 -1
  10. package/build/codegen/collect-models.d.ts +14 -3
  11. package/build/codegen/collect-models.js +15 -5
  12. package/build/codegen/collect-models.js.map +1 -1
  13. package/build/codegen/collect-models.test.js +21 -2
  14. package/build/codegen/collect-models.test.js.map +1 -1
  15. package/build/codegen/emit-index.js +13 -0
  16. package/build/codegen/emit-index.js.map +1 -1
  17. package/build/codegen/emit-index.test.js +25 -0
  18. package/build/codegen/emit-index.test.js.map +1 -1
  19. package/build/codegen/emit-scope.js +45 -8
  20. package/build/codegen/emit-scope.js.map +1 -1
  21. package/build/codegen/emit-scope.test.js +86 -4
  22. package/build/codegen/emit-scope.test.js.map +1 -1
  23. package/build/codegen/index.d.ts +10 -0
  24. package/build/codegen/index.js +3 -0
  25. package/build/codegen/index.js.map +1 -1
  26. package/build/codegen/pipeline.d.ts +4 -0
  27. package/build/codegen/pipeline.js +3 -0
  28. package/build/codegen/pipeline.js.map +1 -1
  29. package/build/codegen/targets/_shared/target-run.d.ts +10 -0
  30. package/build/codegen/targets/ts/run.js +11 -2
  31. package/build/codegen/targets/ts/run.js.map +1 -1
  32. package/build/codegen/targets/ts/shared-models.test.js +97 -1
  33. package/build/codegen/targets/ts/shared-models.test.js.map +1 -1
  34. package/docs/client-and-codegen.md +62 -0
  35. package/docs/client-error-handling.md +87 -0
  36. package/docs/handoffs/2026-06-08-dx-round2-declines.md +45 -0
  37. package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
  38. package/docs/http-integrations.md +25 -0
  39. package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
  40. package/docs/superpowers/plans/2026-06-08-codegen-dx-surfacing.md +428 -0
  41. package/docs/superpowers/specs/2026-06-08-dx-feedback-round-2-design.md +376 -0
  42. package/package.json +1 -1
  43. package/src/codegen/__fixtures__/users-envelope.json +9 -0
  44. package/src/codegen/bin/cli.test.ts +27 -0
  45. package/src/codegen/bin/cli.ts +18 -0
  46. package/src/codegen/bin/flag-specs.test.ts +11 -0
  47. package/src/codegen/bin/flag-specs.ts +2 -0
  48. package/src/codegen/collect-models.test.ts +24 -2
  49. package/src/codegen/collect-models.ts +22 -5
  50. package/src/codegen/emit-index.test.ts +34 -0
  51. package/src/codegen/emit-index.ts +19 -0
  52. package/src/codegen/emit-scope.test.ts +94 -4
  53. package/src/codegen/emit-scope.ts +53 -8
  54. package/src/codegen/index.ts +13 -0
  55. package/src/codegen/pipeline.ts +7 -0
  56. package/src/codegen/targets/_shared/target-run.ts +10 -0
  57. package/src/codegen/targets/kotlin/__fixtures__/users-golden.kt +6 -0
  58. package/src/codegen/targets/swift/__fixtures__/users-golden.swift +6 -0
  59. package/src/codegen/targets/ts/run.ts +18 -1
  60. package/src/codegen/targets/ts/shared-models.test.ts +109 -1
@@ -106,6 +106,25 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
106
106
  '',
107
107
  ]
108
108
 
109
+ // Per-scope client interfaces, derived from the bindings factory's return type
110
+ // (DX #15). Reuses the `ReturnType<typeof factory>` pattern already used by the
111
+ // convenience client below. Lets a consumer type a dependency as the narrow scope
112
+ // port — `constructor(client: UsersClient = api.users)` — and a fake satisfies just
113
+ // that scope with no `as unknown as typeof api` cast. `${Service}Client` is the
114
+ // callable bundle; it does NOT collide with the `${Service}.<Scope>` type namespace.
115
+ // Each type carries a JSDoc line so the DI-seam intent is visible to a developer
116
+ // reading the generated file — not just in this emitter's source comment above.
117
+ const aggregateClientType = `${servicePascal}Client`
118
+ pieces.push(
119
+ `/** Full typed client surface — every scope of \`${servicePascal}\`. */`,
120
+ `export type ${aggregateClientType} = ReturnType<typeof ${factoryName}>`,
121
+ ...groups.flatMap((g) => [
122
+ `/** Narrow port for the \`${g.camelCase}\` scope — inject as a DI seam without casting the aggregate client. */`,
123
+ `export type ${toPascalCase(g.camelCase)}Client = ${aggregateClientType}['${g.camelCase}']`,
124
+ ]),
125
+ '',
126
+ )
127
+
109
128
  // `createApiClient` is a convenience wrapper that wires `createClient` with
110
129
  // the generated error registry baked in, then invokes the bindings factory.
111
130
  // Consumers that want manual control over `createClient` still use the
@@ -1250,9 +1250,10 @@ describe('emitScopeFile API conditional return type', () => {
1250
1250
  expect(out).toContain('{ headers: GetPreflightResponseHeaders }')
1251
1251
  })
1252
1252
 
1253
- it('neither body nor headers: return type is void', async () => {
1253
+ it('neither body nor headers: param type is void, return type is void', async () => {
1254
1254
  const out = await emitScopeFile(apiGroupNoBody)
1255
- expect(out).toContain('client.bindCallable<unknown, void>')
1255
+ expect(out).toContain('client.bindCallable<void, void>')
1256
+ expect(out).not.toContain('client.bindCallable<unknown,')
1256
1257
  })
1257
1258
  })
1258
1259
 
@@ -1276,9 +1277,9 @@ describe('emitScopeFile API conditional return type', () => {
1276
1277
  expect(out).toContain('{ headers: Preflight.GetPreflight.Response.Headers }')
1277
1278
  })
1278
1279
 
1279
- it('neither body nor headers: return type is void', async () => {
1280
+ it('neither body nor headers: param type is void, return type is void', async () => {
1280
1281
  const out = await emitScopeFile(apiGroupNoBody, { namespaceTypes: true })
1281
- expect(out).toContain('client.bindCallable<unknown, void>')
1282
+ expect(out).toContain('client.bindCallable<void, void>')
1282
1283
  })
1283
1284
  })
1284
1285
  })
@@ -1586,3 +1587,92 @@ describe('emitScopeFile http-stream kind', () => {
1586
1587
  })
1587
1588
  })
1588
1589
  })
1590
+
1591
+ // Input-less RPC (no body) and stream (no params) — exercise the void-param fallback
1592
+ // across the RPC and stream emission paths, not just API.
1593
+ const rpcGroupNoInput: ScopeGroup = {
1594
+ scopeKey: 'session',
1595
+ camelCase: 'session',
1596
+ routes: [
1597
+ {
1598
+ kind: 'rpc',
1599
+ name: 'Logout',
1600
+ path: '/session/logout',
1601
+ method: 'post',
1602
+ scope: 'session',
1603
+ version: 1,
1604
+ jsonSchema: {
1605
+ response: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] },
1606
+ },
1607
+ } satisfies RPCHttpRouteDoc,
1608
+ ],
1609
+ }
1610
+
1611
+ const streamGroupNoParams: ScopeGroup = {
1612
+ scopeKey: 'ticks',
1613
+ camelCase: 'ticks',
1614
+ routes: [
1615
+ {
1616
+ kind: 'stream',
1617
+ name: 'WatchTicks',
1618
+ path: '/ticks/stream',
1619
+ methods: ['get'],
1620
+ streamMode: 'sse',
1621
+ scope: 'ticks',
1622
+ version: 1,
1623
+ jsonSchema: {
1624
+ params: undefined,
1625
+ yieldType: { type: 'object', properties: { n: { type: 'number' } }, required: ['n'] },
1626
+ returnType: undefined,
1627
+ },
1628
+ } satisfies StreamHttpRouteDoc,
1629
+ ],
1630
+ }
1631
+
1632
+ describe('emitScopeFile input-less routes (void params)', () => {
1633
+ it('RPC route with no body emits void as the params type arg', async () => {
1634
+ const out = await emitScopeFile(rpcGroupNoInput)
1635
+ expect(out).toContain('client.bindCallable<void,')
1636
+ expect(out).not.toContain('client.bindCallable<unknown,')
1637
+ })
1638
+
1639
+ it('stream route with no params emits void as the params type', async () => {
1640
+ const out = await emitScopeFile(streamGroupNoParams)
1641
+ // Stream callables are direct methods: WatchTicks(params: void, options?)
1642
+ expect(out).toContain('WatchTicks(params: void')
1643
+ expect(out).not.toContain('WatchTicks(params: unknown')
1644
+ })
1645
+ })
1646
+
1647
+ describe('emitScopeFile callable JSDoc surfaces options + errors', () => {
1648
+ it('mentions the per-call options bag on an RPC callable', async () => {
1649
+ const out = await emitScopeFile(rpcGroup)
1650
+ expect(out).toContain('POST') // existing method label preserved
1651
+ expect(out).toContain('@param options')
1652
+ expect(out).toContain('ProcedureCallOptions')
1653
+ expect(out).toContain('signal')
1654
+ })
1655
+
1656
+ it('mentions declared errors + Scope.Route.Errors on a route with errors (namespace)', async () => {
1657
+ const out = await emitScopeFile(rpcGroupWithErrors, {
1658
+ namespaceTypes: true,
1659
+ errorKeys: new Set(['NotFound']),
1660
+ serviceName: 'Api',
1661
+ })
1662
+ expect(out).toContain('@throws')
1663
+ expect(out).toContain('Users.GetUser.Errors')
1664
+ expect(out).toContain('instanceof')
1665
+ })
1666
+
1667
+ it('omits the @throws line when a route declares no errors', async () => {
1668
+ const out = await emitScopeFile(rpcGroupNoErrors, { serviceName: 'Api' })
1669
+ expect(out).not.toContain('@throws')
1670
+ expect(out).toContain('@param options') // options line still present
1671
+ })
1672
+
1673
+ it('mentions options on a stream callable but no @throws', async () => {
1674
+ const out = await emitScopeFile(streamGroup)
1675
+ expect(out).toContain('@param options')
1676
+ expect(out).not.toContain('@throws')
1677
+ })
1678
+ })
@@ -387,6 +387,35 @@ function injectRouteErrors(
387
387
  // Route emitters
388
388
  // ---------------------------------------------------------------------------
389
389
 
390
+ /**
391
+ * Builds the multi-line JSDoc comment for a route callable. Surfaces the
392
+ * second `options` argument (the per-call AbortSignal/timeout seam — DX #8) and,
393
+ * for routes that declare typed errors, points at the route's `Errors` type and
394
+ * how to narrow it on the throwing path (DX #10). Indented for placement inside
395
+ * the bind-object (4 spaces).
396
+ */
397
+ function buildCallableJsDoc(opts: {
398
+ methodLabel: string
399
+ path: string
400
+ errorsRef: string | null
401
+ }): string {
402
+ const lines = [
403
+ ` /**`,
404
+ ` * ${opts.methodLabel} ${opts.path}`,
405
+ ` *`,
406
+ ` * @param options Optional per-call {@link ProcedureCallOptions} —`,
407
+ ` * \`signal\` (cancel on dispose), \`timeout\`, \`headers\`, \`basePath\`.`,
408
+ ]
409
+ if (opts.errorsRef) {
410
+ lines.push(
411
+ ` * @throws Declared typed errors: {@link ${opts.errorsRef}}. Narrow with`,
412
+ ` * \`instanceof\` on the throwing path, or call \`.safe()\` for a \`Result\`.`,
413
+ )
414
+ }
415
+ lines.push(` */`)
416
+ return lines.join('\n')
417
+ }
418
+
390
419
  async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
391
420
  const pascal = versionedPascal(route.name, route.version)
392
421
 
@@ -395,7 +424,7 @@ async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Prom
395
424
  { shortName: 'Response', schema: route.jsonSchema.response },
396
425
  ], ctx)
397
426
 
398
- const paramsTypeName = refs['Params'] ?? 'unknown'
427
+ const paramsTypeName = refs['Params'] ?? 'void'
399
428
  const responseTypeName = refs['Response'] ?? 'unknown'
400
429
  const scopeStr = Array.isArray(route.scope) ? route.scope.join('-') : route.scope
401
430
 
@@ -409,7 +438,11 @@ async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Prom
409
438
  : `client.bindCallable<${paramsTypeName}, ${responseTypeName}>`
410
439
 
411
440
  const callable = [
412
- ` /** ${route.method.toUpperCase()} ${route.path} */`,
441
+ buildCallableJsDoc({
442
+ methodLabel: route.method.toUpperCase(),
443
+ path: route.path,
444
+ errorsRef: hasErrors ? errorsRef : null,
445
+ }),
413
446
  ` ${pascal}: ${helperCall}({`,
414
447
  ` name: '${pascal}',`,
415
448
  ` scope: '${scopeStr}',`,
@@ -533,7 +566,7 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
533
566
  : `${pascal}Errors`
534
567
 
535
568
  const declarations: string[] = []
536
- let paramsTypeName = 'unknown'
569
+ let paramsTypeName = 'void'
537
570
  let returnTypeName = 'void'
538
571
 
539
572
  // Track reserved names across all sub-namespaces. Model names are reserved
@@ -638,7 +671,11 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
638
671
  ]
639
672
 
640
673
  const callable = [
641
- ` /** ${route.method.toUpperCase()} ${route.fullPath} */`,
674
+ buildCallableJsDoc({
675
+ methodLabel: route.method.toUpperCase(),
676
+ path: route.fullPath,
677
+ errorsRef: hasErrors ? errorsRef : null,
678
+ }),
642
679
  ` ${route.name}: ${helperCall}({`,
643
680
  ...descriptorLines,
644
681
  ` }),`,
@@ -676,7 +713,7 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
676
713
 
677
714
  const scopeStr = route.scope ?? 'default'
678
715
  const declarations: string[] = []
679
- let paramsTypeName = 'unknown'
716
+ let paramsTypeName = 'void'
680
717
  let yieldTypeName = 'unknown'
681
718
  let returnTypeName = 'void'
682
719
 
@@ -774,7 +811,11 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
774
811
  }
775
812
 
776
813
  const callable = [
777
- ` /** ${route.method.toUpperCase()} ${route.fullPath} */`,
814
+ buildCallableJsDoc({
815
+ methodLabel: route.method.toUpperCase(),
816
+ path: route.fullPath,
817
+ errorsRef: null,
818
+ }),
778
819
  ` ${route.name}(req: ${paramsTypeName}, options?: ProcedureCallOptions): TypedStream<${yieldTypeName}, ${returnTypeName}> {`,
779
820
  ` return client.stream<${yieldTypeName}, ${returnTypeName}>({`,
780
821
  ` name: '${route.name}',`,
@@ -813,13 +854,17 @@ async function emitStreamRoute(route: StreamHttpRouteDoc, ctx: EmitRouteContext)
813
854
  { shortName: 'Return', schema: route.jsonSchema.returnType },
814
855
  ], ctx)
815
856
 
816
- const paramsTypeName = refs['Params'] ?? 'unknown'
857
+ const paramsTypeName = refs['Params'] ?? 'void'
817
858
  const yieldTypeName = refs['Yield'] ?? 'unknown'
818
859
  const returnTypeName = refs['Return'] ?? 'void'
819
860
  const scopeStr = Array.isArray(route.scope) ? route.scope.join('-') : route.scope
820
861
 
821
862
  const callable = [
822
- ` /** ${route.methods.map((m) => m.toUpperCase()).join('|')} ${route.path} */`,
863
+ buildCallableJsDoc({
864
+ methodLabel: route.methods.map((m) => m.toUpperCase()).join('|'),
865
+ path: route.path,
866
+ errorsRef: null,
867
+ }),
823
868
  ` ${pascal}(params: ${paramsTypeName}, options?: ProcedureCallOptions): TypedStream<${yieldTypeName}, ${returnTypeName}> {`,
824
869
  ` return client.stream<${yieldTypeName}, ${returnTypeName}>({`,
825
870
  ` name: '${pascal}',`,
@@ -18,6 +18,16 @@ export interface GenerateClientOptions extends ResolveInput {
18
18
  shareModels?: boolean
19
19
  /** Maps a model `$id` to an external import so a shared model is re-exported rather than generated. */
20
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
21
31
  target?: 'ts' | 'kotlin' | 'swift'
22
32
  kotlinPackage?: string
23
33
  kotlinSerializer?: 'kotlinx' | 'none'
@@ -44,6 +54,9 @@ export async function generateClient(options: GenerateClientOptions): Promise<Ge
44
54
  cleanOutDir: options.cleanOutDir,
45
55
  shareModels: options.shareModels,
46
56
  sharedTypesImport: options.sharedTypesImport,
57
+ sharedModelsModule: options.sharedModelsModule,
58
+ strictSharedModels: options.strictSharedModels,
59
+ logger: options.logger,
47
60
  target: options.target,
48
61
  kotlinPackage: options.kotlinPackage,
49
62
  kotlinSerializer: options.kotlinSerializer,
@@ -23,6 +23,10 @@ export interface PipelineOptions {
23
23
  cleanOutDir?: boolean
24
24
  shareModels?: boolean
25
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
26
30
  target?: 'ts' | 'kotlin' | 'swift'
27
31
  kotlinPackage?: string
28
32
  kotlinSerializer?: 'kotlinx' | 'none'
@@ -72,6 +76,9 @@ export async function runPipeline(options: PipelineOptions): Promise<GeneratedFi
72
76
  cleanOutDir,
73
77
  shareModels,
74
78
  sharedTypesImport: options.sharedTypesImport,
79
+ sharedModelsModule: options.sharedModelsModule,
80
+ strictSharedModels: options.strictSharedModels,
81
+ logger: options.logger,
75
82
  }
76
83
 
77
84
  switch (options.target) {
@@ -30,4 +30,14 @@ export interface TargetRunInput {
30
30
  shareModels?: boolean
31
31
  /** Maps a model `$id` to an external import (re-exported instead of generated). */
32
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
33
43
  }
@@ -192,4 +192,10 @@ object Users {
192
192
  val count: Long,
193
193
  )
194
194
  }
195
+
196
+ object Heartbeat {
197
+ const val method = "GET"
198
+ const val pathTemplate = "/users/heartbeat"
199
+ const val path = "/users/heartbeat"
200
+ }
195
201
  }
@@ -199,4 +199,10 @@ public enum Users {
199
199
  public let count: Int64
200
200
  }
201
201
  }
202
+
203
+ public enum Heartbeat {
204
+ public static let method = "GET"
205
+ public static let pathTemplate = "/users/heartbeat"
206
+ public static let path = "/users/heartbeat"
207
+ }
202
208
  }
@@ -33,6 +33,9 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
33
33
  selfContained = false,
34
34
  cleanOutDir = false,
35
35
  sharedTypesImport,
36
+ sharedModelsModule,
37
+ strictSharedModels = false,
38
+ logger,
36
39
  } = input
37
40
  const shareModels = input.shareModels ?? false
38
41
 
@@ -60,7 +63,7 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
60
63
  // covers ALL models (generated AND externally-imported) since scopes always
61
64
  // import shared types from `./_models`.
62
65
  const models = shareModels
63
- ? resolveModelImports(collectModels(envelope.routes), sharedTypesImport)
66
+ ? resolveModelImports(collectModels(envelope.routes), { sharedTypesImport, sharedModelsModule })
64
67
  : []
65
68
  const idToModelName = new Map(models.map((m) => [m.id, m.name]))
66
69
 
@@ -74,6 +77,20 @@ export async function runTsPipeline(input: TsRunInput): Promise<GeneratedFile[]>
74
77
  }
75
78
  }
76
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
+
77
94
  const files: GeneratedFile[] = []
78
95
 
79
96
  for (const group of groups) {
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest'
1
+ import { describe, it, expect, vi } from 'vitest'
2
2
  import { runPipeline } from '../../pipeline.js'
3
3
  import type { DocEnvelope } from '../../../implementations/types.js'
4
4
  import type { GeneratedFile } from '../_shared/write-files.js'
@@ -233,6 +233,114 @@ describe('shared models (TS target, ajsc x-named-type)', () => {
233
233
  expect(messages.code).toContain("import type { Message } from './_models'")
234
234
  })
235
235
 
236
+ it('sharedModelsModule re-exports every $id model from the convention module', async () => {
237
+ const files = await runPipeline({
238
+ envelope: modelEnvelope(),
239
+ outDir: 'out',
240
+ dryRun: true,
241
+ shareModels: true,
242
+ namespaceTypes: true,
243
+ selfContained: false,
244
+ sharedModelsModule: '@app/schemas',
245
+ })
246
+
247
+ const modelsFile = findFile(files, '_models.ts')!
248
+ // Re-exported, not generated.
249
+ expect(modelsFile.code).toContain("export { Message } from '@app/schemas'")
250
+ expect(countMatches(modelsFile.code, /export type Message =/g)).toBe(0)
251
+
252
+ // Scopes still import the shared name from ./_models.
253
+ expect(findFile(files, 'messages.ts')!.code).toContain("from './_models'")
254
+ })
255
+
256
+ it('explicit sharedTypesImport overrides the sharedModelsModule convention per $id', async () => {
257
+ const files = await runPipeline({
258
+ envelope: modelEnvelope(),
259
+ outDir: 'out',
260
+ dryRun: true,
261
+ shareModels: true,
262
+ namespaceTypes: true,
263
+ selfContained: false,
264
+ sharedModelsModule: '@app/schemas',
265
+ sharedTypesImport: { 'urn:msg': { module: '@override/pkg', name: 'Message' } },
266
+ })
267
+ expect(findFile(files, '_models.ts')!.code).toContain("export { Message } from '@override/pkg'")
268
+ })
269
+
270
+ it('emits a neutral summary of the re-exported / generated split via the injected logger', async () => {
271
+ const lines: string[] = []
272
+ await runPipeline({
273
+ envelope: modelEnvelope(),
274
+ outDir: 'out',
275
+ dryRun: true,
276
+ shareModels: true,
277
+ namespaceTypes: true,
278
+ selfContained: false,
279
+ logger: (m) => lines.push(m),
280
+ })
281
+ expect(lines.join('\n')).toContain('Shared models: 1 total — 0 re-exported, 1 generated locally.')
282
+ })
283
+
284
+ it('does not emit a summary when there are no $id-bearing models', async () => {
285
+ const lines: string[] = []
286
+ await runPipeline({
287
+ envelope: noModelEnvelope(),
288
+ outDir: 'out',
289
+ dryRun: true,
290
+ shareModels: true,
291
+ namespaceTypes: true,
292
+ selfContained: false,
293
+ logger: (m) => lines.push(m),
294
+ })
295
+ expect(lines.join('\n')).not.toContain('Shared models:')
296
+ })
297
+
298
+ it('stays silent (no console output) when no logger is injected', async () => {
299
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
300
+ try {
301
+ await runPipeline({
302
+ envelope: modelEnvelope(),
303
+ outDir: 'out',
304
+ dryRun: true,
305
+ shareModels: true,
306
+ namespaceTypes: true,
307
+ selfContained: false,
308
+ })
309
+ expect(log.mock.calls.flat().join('\n')).not.toContain('Shared models:')
310
+ } finally {
311
+ log.mockRestore()
312
+ }
313
+ })
314
+
315
+ it('strictSharedModels throws listing the offending $id and derived name', async () => {
316
+ await expect(
317
+ runPipeline({
318
+ envelope: modelEnvelope(),
319
+ outDir: 'out',
320
+ dryRun: true,
321
+ shareModels: true,
322
+ namespaceTypes: true,
323
+ selfContained: false,
324
+ strictSharedModels: true,
325
+ }),
326
+ ).rejects.toThrow(/--strict-shared-models[\s\S]*urn:msg[\s\S]*Message/)
327
+ })
328
+
329
+ it('strictSharedModels passes once every model is covered by the convention', async () => {
330
+ await expect(
331
+ runPipeline({
332
+ envelope: modelEnvelope(),
333
+ outDir: 'out',
334
+ dryRun: true,
335
+ shareModels: true,
336
+ namespaceTypes: true,
337
+ selfContained: false,
338
+ strictSharedModels: true,
339
+ sharedModelsModule: '@app/schemas',
340
+ }),
341
+ ).resolves.toBeDefined()
342
+ })
343
+
236
344
  it('fails loudly when a shared model name collides with a property-derived sub-type', async () => {
237
345
  // `latest` references the Message model; a sibling property literally named
238
346
  // `message` would make ajsc extract a structural sub-type ALSO named