ts-procedures 8.4.0 → 8.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/codegen/bin/cli.d.ts +4 -0
- package/build/codegen/bin/cli.js +16 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +18 -0
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/bin/flag-specs.js +2 -0
- package/build/codegen/bin/flag-specs.js.map +1 -1
- package/build/codegen/bin/flag-specs.test.js +9 -0
- package/build/codegen/bin/flag-specs.test.js.map +1 -1
- package/build/codegen/collect-models.d.ts +14 -3
- package/build/codegen/collect-models.js +15 -5
- package/build/codegen/collect-models.js.map +1 -1
- package/build/codegen/collect-models.test.js +21 -2
- package/build/codegen/collect-models.test.js.map +1 -1
- package/build/codegen/index.d.ts +10 -0
- package/build/codegen/index.js +3 -0
- package/build/codegen/index.js.map +1 -1
- package/build/codegen/pipeline.d.ts +4 -0
- package/build/codegen/pipeline.js +3 -0
- package/build/codegen/pipeline.js.map +1 -1
- package/build/codegen/targets/_shared/target-run.d.ts +10 -0
- package/build/codegen/targets/ts/run.js +11 -2
- package/build/codegen/targets/ts/run.js.map +1 -1
- package/build/codegen/targets/ts/shared-models.test.js +97 -1
- package/build/codegen/targets/ts/shared-models.test.js.map +1 -1
- package/docs/client-and-codegen.md +62 -0
- package/docs/handoffs/shared-models-auto-resolve-response.md +181 -0
- package/docs/superpowers/plans/2026-06-06-shared-models-convention-and-diagnostics.md +659 -0
- package/package.json +1 -1
- package/src/codegen/bin/cli.test.ts +27 -0
- package/src/codegen/bin/cli.ts +18 -0
- package/src/codegen/bin/flag-specs.test.ts +11 -0
- package/src/codegen/bin/flag-specs.ts +2 -0
- package/src/codegen/collect-models.test.ts +24 -2
- package/src/codegen/collect-models.ts +22 -5
- package/src/codegen/index.ts +13 -0
- package/src/codegen/pipeline.ts +7 -0
- package/src/codegen/targets/_shared/target-run.ts +10 -0
- package/src/codegen/targets/ts/run.ts +18 -1
- package/src/codegen/targets/ts/shared-models.test.ts +109 -1
|
@@ -555,3 +555,30 @@ describe('--share-models', () => {
|
|
|
555
555
|
expect(parseArgs(['--out', 'g', '--url', 'u'], cfg as CodegenConfig).sharedTypesImport).toEqual(cfg.sharedTypesImport)
|
|
556
556
|
})
|
|
557
557
|
})
|
|
558
|
+
|
|
559
|
+
describe('--shared-models-module and --strict-shared-models', () => {
|
|
560
|
+
it('parses --shared-models-module into sharedModelsModule', () => {
|
|
561
|
+
const parsed = parseArgs(['--out', 'gen', '--file', 'e.json', '--shared-models-module', '@app/schemas'])
|
|
562
|
+
expect(parsed.sharedModelsModule).toBe('@app/schemas')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('parses --strict-shared-models as a boolean (default false)', () => {
|
|
566
|
+
expect(parseArgs(['--out', 'gen', '--file', 'e.json']).strictSharedModels).toBe(false)
|
|
567
|
+
expect(
|
|
568
|
+
parseArgs(['--out', 'gen', '--file', 'e.json', '--strict-shared-models']).strictSharedModels,
|
|
569
|
+
).toBe(true)
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('CLI --shared-models-module overrides a config value', () => {
|
|
573
|
+
const parsed = parseArgs(
|
|
574
|
+
['--out', 'gen', '--file', 'e.json', '--shared-models-module', '@cli/pkg'],
|
|
575
|
+
{ sharedModelsModule: '@config/pkg' },
|
|
576
|
+
)
|
|
577
|
+
expect(parsed.sharedModelsModule).toBe('@cli/pkg')
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it('strictSharedModels is seeded from config when the flag is absent', () => {
|
|
581
|
+
const parsed = parseArgs(['--out', 'gen', '--file', 'e.json'], { strictSharedModels: true })
|
|
582
|
+
expect(parsed.strictSharedModels).toBe(true)
|
|
583
|
+
})
|
|
584
|
+
})
|
package/src/codegen/bin/cli.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface CodegenConfig {
|
|
|
30
30
|
unsupportedUnions?: 'throw' | 'fallback'
|
|
31
31
|
shareModels?: boolean
|
|
32
32
|
sharedTypesImport?: SharedTypesImportMap
|
|
33
|
+
sharedModelsModule?: string
|
|
34
|
+
strictSharedModels?: boolean
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export interface ParsedArgs {
|
|
@@ -51,6 +53,8 @@ export interface ParsedArgs {
|
|
|
51
53
|
unsupportedUnions?: 'throw' | 'fallback'
|
|
52
54
|
shareModels: boolean
|
|
53
55
|
sharedTypesImport?: SharedTypesImportMap
|
|
56
|
+
sharedModelsModule?: string
|
|
57
|
+
strictSharedModels: boolean
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// ---------------------------------------------------------------------------
|
|
@@ -170,6 +174,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
170
174
|
let unsupportedUnions: 'throw' | 'fallback' | undefined = config?.unsupportedUnions
|
|
171
175
|
let shareModels = config?.shareModels ?? true
|
|
172
176
|
const sharedTypesImport = config?.sharedTypesImport
|
|
177
|
+
let sharedModelsModule: string | undefined = config?.sharedModelsModule
|
|
178
|
+
let strictSharedModels = config?.strictSharedModels ?? false
|
|
173
179
|
let configPath: string | undefined
|
|
174
180
|
|
|
175
181
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -260,6 +266,10 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
260
266
|
shareModels = true
|
|
261
267
|
} else if (arg === '--no-share-models') {
|
|
262
268
|
shareModels = false
|
|
269
|
+
} else if (arg === '--shared-models-module') {
|
|
270
|
+
sharedModelsModule = argv[++i]
|
|
271
|
+
} else if (arg === '--strict-shared-models') {
|
|
272
|
+
strictSharedModels = true
|
|
263
273
|
} else if (arg === '--config') {
|
|
264
274
|
configPath = argv[++i]
|
|
265
275
|
} else if (arg !== undefined && arg.startsWith('--')) {
|
|
@@ -336,6 +346,8 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
336
346
|
...(unsupportedUnions !== undefined ? { unsupportedUnions } : {}),
|
|
337
347
|
shareModels,
|
|
338
348
|
...(sharedTypesImport !== undefined ? { sharedTypesImport } : {}),
|
|
349
|
+
...(sharedModelsModule !== undefined ? { sharedModelsModule } : {}),
|
|
350
|
+
strictSharedModels,
|
|
339
351
|
}
|
|
340
352
|
}
|
|
341
353
|
|
|
@@ -425,6 +437,9 @@ async function runWithWatch(parsed: ParsedArgs): Promise<void> {
|
|
|
425
437
|
...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
|
|
426
438
|
shareModels: parsed.shareModels,
|
|
427
439
|
...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
|
|
440
|
+
...(parsed.sharedModelsModule !== undefined ? { sharedModelsModule: parsed.sharedModelsModule } : {}),
|
|
441
|
+
strictSharedModels: parsed.strictSharedModels,
|
|
442
|
+
logger: (message: string) => { console.log(message) },
|
|
428
443
|
...kotlinWiring,
|
|
429
444
|
...swiftWiring,
|
|
430
445
|
})
|
|
@@ -555,6 +570,9 @@ async function main(): Promise<void> {
|
|
|
555
570
|
...(parsed.swift?.accessLevel !== undefined ? { swiftAccessLevel: parsed.swift.accessLevel } : {}),
|
|
556
571
|
shareModels: parsed.shareModels,
|
|
557
572
|
...(parsed.sharedTypesImport !== undefined ? { sharedTypesImport: parsed.sharedTypesImport } : {}),
|
|
573
|
+
...(parsed.sharedModelsModule !== undefined ? { sharedModelsModule: parsed.sharedModelsModule } : {}),
|
|
574
|
+
strictSharedModels: parsed.strictSharedModels,
|
|
575
|
+
logger: (message: string) => { console.log(message) },
|
|
558
576
|
...kotlinWiring,
|
|
559
577
|
...swiftWiring,
|
|
560
578
|
})
|
|
@@ -24,4 +24,15 @@ describe('flag-specs', () => {
|
|
|
24
24
|
expect(KNOWN_FLAGS).not.toContain('--help')
|
|
25
25
|
expect(KNOWN_FLAGS).not.toContain('-h')
|
|
26
26
|
})
|
|
27
|
+
|
|
28
|
+
it('catalogs the shared-models convention + strict flags', () => {
|
|
29
|
+
expect(KNOWN_FLAGS).toContain('--shared-models-module')
|
|
30
|
+
expect(KNOWN_FLAGS).toContain('--strict-shared-models')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('documents the new flags in --help output', () => {
|
|
34
|
+
const help = formatHelp()
|
|
35
|
+
expect(help).toContain('--shared-models-module')
|
|
36
|
+
expect(help).toContain('--strict-shared-models')
|
|
37
|
+
})
|
|
27
38
|
})
|
|
@@ -27,6 +27,8 @@ export const FLAG_SPECS: readonly FlagSpec[] = [
|
|
|
27
27
|
{ name: '--client-import-path', arg: '<path>', description: 'Override the client runtime import path', group: 'Codegen' },
|
|
28
28
|
{ name: '--share-models', description: 'Hoist $id-bearing schemas into a shared _models.ts', group: 'Codegen', default: 'on' },
|
|
29
29
|
{ name: '--no-share-models', description: 'Inline every type per route (legacy behaviour)', group: 'Codegen' },
|
|
30
|
+
{ name: '--shared-models-module', arg: '<module>', description: 'Re-export every $id model from one module (convention; sharedTypesImport overrides)', group: 'Codegen' },
|
|
31
|
+
{ name: '--strict-shared-models', description: 'Fail if any $id model would be generated as a local twin', group: 'Codegen' },
|
|
30
32
|
{ name: '--jsdoc', description: 'Emit JSDoc on generated types', group: 'Codegen', default: 'on' },
|
|
31
33
|
{ name: '--no-jsdoc', description: 'Suppress JSDoc', group: 'Codegen' },
|
|
32
34
|
{ name: '--enum-style', arg: '<union|enum>', description: 'How to emit enums (namespace mode)', group: 'Codegen' },
|
|
@@ -40,7 +40,29 @@ it('does NOT throw for ordinary schemas without the reserved prefix', () => {
|
|
|
40
40
|
|
|
41
41
|
it('resolveModelImports tags mapped models and leaves others generated', () => {
|
|
42
42
|
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
43
|
-
const mapped = resolveModelImports(models, {
|
|
43
|
+
const mapped = resolveModelImports(models, {
|
|
44
|
+
sharedTypesImport: { 'urn:msg': { module: '@shared/schemas', name: 'Message' } },
|
|
45
|
+
})
|
|
44
46
|
expect(mapped[0]?.import).toEqual({ module: '@shared/schemas', name: 'Message' })
|
|
45
|
-
expect(resolveModelImports(models
|
|
47
|
+
expect(resolveModelImports(models)[0]?.import).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('resolveModelImports falls back to sharedModelsModule when no map entry matches', () => {
|
|
51
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
52
|
+
const resolved = resolveModelImports(models, { sharedModelsModule: '@app/schemas' })
|
|
53
|
+
expect(resolved[0]?.import).toEqual({ module: '@app/schemas', name: 'Message' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('resolveModelImports: explicit map entry wins over the convention', () => {
|
|
57
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
58
|
+
const resolved = resolveModelImports(models, {
|
|
59
|
+
sharedTypesImport: { 'urn:msg': { module: '@override/pkg', name: 'Msg' } },
|
|
60
|
+
sharedModelsModule: '@app/schemas',
|
|
61
|
+
})
|
|
62
|
+
expect(resolved[0]?.import).toEqual({ module: '@override/pkg', name: 'Msg' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('resolveModelImports: empty-string convention is treated as unset (generated locally)', () => {
|
|
66
|
+
const models = [{ id: 'urn:msg', name: 'Message', schema: {} as any }]
|
|
67
|
+
expect(resolveModelImports(models, { sharedModelsModule: '' })[0]?.import).toBeUndefined()
|
|
46
68
|
})
|
|
@@ -93,16 +93,33 @@ export function collectModels(routes: AnyHttpRouteDoc[]): CollectedModel[] {
|
|
|
93
93
|
})
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Options controlling how collected models are resolved to external imports. */
|
|
97
|
+
export interface ResolveModelImportsOptions {
|
|
98
|
+
/** Per-`$id` external import. A matching entry wins over `sharedModelsModule`. */
|
|
99
|
+
sharedTypesImport?: SharedTypesImportMap
|
|
100
|
+
/** Single module every otherwise-unmapped `$id` model re-exports from (convention). */
|
|
101
|
+
sharedModelsModule?: string
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
/**
|
|
97
|
-
* Tags each collected model with its external import
|
|
98
|
-
*
|
|
105
|
+
* Tags each collected model with its external import. Precedence:
|
|
106
|
+
* 1. an explicit `sharedTypesImport[$id]` entry (per-type override / rename),
|
|
107
|
+
* 2. otherwise, when `sharedModelsModule` is set, the convention
|
|
108
|
+
* `{ module: sharedModelsModule, name: model.name }` — every shared model
|
|
109
|
+
* re-exports from one module under its derived name (`$id`/`title` === export),
|
|
110
|
+
* 3. otherwise `import` stays undefined and the model is generated locally.
|
|
99
111
|
*/
|
|
100
112
|
export function resolveModelImports(
|
|
101
113
|
models: CollectedModel[],
|
|
102
|
-
|
|
114
|
+
options: ResolveModelImportsOptions = {},
|
|
103
115
|
): ResolvedModel[] {
|
|
116
|
+
const { sharedTypesImport = {}, sharedModelsModule } = options
|
|
104
117
|
return models.map((model) => {
|
|
105
|
-
const mapped =
|
|
106
|
-
|
|
118
|
+
const mapped = sharedTypesImport[model.id]
|
|
119
|
+
if (mapped) return { ...model, import: mapped }
|
|
120
|
+
if (sharedModelsModule != null && sharedModelsModule !== '') {
|
|
121
|
+
return { ...model, import: { module: sharedModelsModule, name: model.name } }
|
|
122
|
+
}
|
|
123
|
+
return { ...model }
|
|
107
124
|
})
|
|
108
125
|
}
|
package/src/codegen/index.ts
CHANGED
|
@@ -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,
|
package/src/codegen/pipeline.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|