ts-procedures 7.0.0 → 7.1.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/api-reference.md +4 -0
- package/agent_config/copilot/copilot-instructions.md +2 -0
- package/agent_config/cursor/cursorrules +2 -0
- package/build/codegen/bin/cli.js +91 -0
- package/build/codegen/bin/cli.js.map +1 -1
- package/build/codegen/bin/cli.test.js +15 -0
- package/build/codegen/bin/cli.test.js.map +1 -1
- package/build/codegen/e2e.test.js +97 -74
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-index.js +11 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +58 -16
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +164 -2
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/build/codegen/emit-types.d.ts +28 -0
- package/build/codegen/emit-types.js +69 -5
- package/build/codegen/emit-types.js.map +1 -1
- package/build/codegen/emit-types.test.js +30 -0
- package/build/codegen/emit-types.test.js.map +1 -1
- package/build/codegen/resolve-envelope.js +4 -1
- package/build/codegen/resolve-envelope.js.map +1 -1
- package/build/codegen/resolve-envelope.test.js +10 -0
- package/build/codegen/resolve-envelope.test.js.map +1 -1
- package/build/codegen/test-helpers/run-tsc.d.ts +33 -0
- package/build/codegen/test-helpers/run-tsc.js +49 -0
- package/build/codegen/test-helpers/run-tsc.js.map +1 -0
- package/package.json +1 -1
- package/src/codegen/bin/cli.test.ts +26 -0
- package/src/codegen/bin/cli.ts +91 -0
- package/src/codegen/e2e.test.ts +100 -78
- package/src/codegen/emit-index.ts +11 -1
- package/src/codegen/emit-scope.test.ts +172 -2
- package/src/codegen/emit-scope.ts +66 -13
- package/src/codegen/emit-types.test.ts +34 -0
- package/src/codegen/emit-types.ts +83 -5
- package/src/codegen/resolve-envelope.test.ts +11 -0
- package/src/codegen/resolve-envelope.ts +4 -1
- package/src/codegen/test-helpers/run-tsc.ts +56 -0
package/src/codegen/bin/cli.ts
CHANGED
|
@@ -72,6 +72,87 @@ export async function loadConfigFile(configPath?: string): Promise<CodegenConfig
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Flag catalog + did-you-mean
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
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
|
+
/**
|
|
101
|
+
* Levenshtein distance between two strings — small ad-hoc implementation
|
|
102
|
+
* tuned for short flag names. We don't pull a dep just for typo suggestions.
|
|
103
|
+
*/
|
|
104
|
+
function editDistance(a: string, b: string): number {
|
|
105
|
+
if (a === b) return 0
|
|
106
|
+
if (a.length === 0) return b.length
|
|
107
|
+
if (b.length === 0) return a.length
|
|
108
|
+
// One row at a time — O(min(a, b)) memory.
|
|
109
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
110
|
+
for (let i = 1; i <= a.length; i++) {
|
|
111
|
+
const curr = [i]
|
|
112
|
+
for (let j = 1; j <= b.length; j++) {
|
|
113
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
114
|
+
curr.push(Math.min(curr[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost))
|
|
115
|
+
}
|
|
116
|
+
prev = curr
|
|
117
|
+
}
|
|
118
|
+
return prev[b.length]!
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Returns the known flag closest to `unknown`, or `undefined` when nothing
|
|
123
|
+
* is a confident enough match. Two heuristics:
|
|
124
|
+
*
|
|
125
|
+
* 1. Prefix match — if the unknown flag is a strict prefix of a known one
|
|
126
|
+
* (e.g. `--service` → `--service-name`), suggest it. This catches the
|
|
127
|
+
* common "wrong segment count" typo regardless of how long the missing
|
|
128
|
+
* tail is.
|
|
129
|
+
* 2. Edit distance ≤ 3 — catches transpositions and small misspellings
|
|
130
|
+
* (`--targt` → `--target`). Above 3 the suggestion stops being helpful
|
|
131
|
+
* and starts being misleading.
|
|
132
|
+
*/
|
|
133
|
+
function closestKnownFlag(unknown: string): string | undefined {
|
|
134
|
+
// 1) Prefix match — pick the shortest known flag whose prefix is `unknown`.
|
|
135
|
+
let prefixMatch: string | undefined
|
|
136
|
+
for (const flag of KNOWN_FLAGS) {
|
|
137
|
+
if (flag.startsWith(unknown) && flag !== unknown) {
|
|
138
|
+
if (prefixMatch == null || flag.length < prefixMatch.length) {
|
|
139
|
+
prefixMatch = flag
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (prefixMatch != null) return prefixMatch
|
|
144
|
+
|
|
145
|
+
// 2) Edit-distance fallback.
|
|
146
|
+
let best: { flag: string; distance: number } | undefined
|
|
147
|
+
for (const flag of KNOWN_FLAGS) {
|
|
148
|
+
const distance = editDistance(unknown, flag)
|
|
149
|
+
if (best == null || distance < best.distance) {
|
|
150
|
+
best = { flag, distance }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return best != null && best.distance <= 3 ? best.flag : undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
75
156
|
// ---------------------------------------------------------------------------
|
|
76
157
|
// parseArgs
|
|
77
158
|
// ---------------------------------------------------------------------------
|
|
@@ -190,6 +271,16 @@ export function parseArgs(argv: string[], config?: CodegenConfig): ParsedArgs {
|
|
|
190
271
|
}
|
|
191
272
|
} else if (arg === '--config') {
|
|
192
273
|
configPath = argv[++i]
|
|
274
|
+
} else if (arg !== undefined && arg.startsWith('--')) {
|
|
275
|
+
// Reject unknown flags loudly. Silently ignoring them led to a downstream
|
|
276
|
+
// bug where a misspelled `--targt` produced an empty envelope with no
|
|
277
|
+
// user-visible cause. Suggest the closest known flag when the typo is
|
|
278
|
+
// small enough that the suggestion will be more help than hindrance.
|
|
279
|
+
const suggestion = closestKnownFlag(arg)
|
|
280
|
+
const tail = suggestion != null
|
|
281
|
+
? ` Did you mean \`${suggestion}\`?`
|
|
282
|
+
: ' See the README for the supported flag set.'
|
|
283
|
+
throw new Error(`[ts-procedures-codegen] Unknown CLI flag: ${arg}.${tail}`)
|
|
193
284
|
}
|
|
194
285
|
}
|
|
195
286
|
|
package/src/codegen/e2e.test.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, afterEach } from 'vitest'
|
|
2
2
|
import { generateClient } from './index.js'
|
|
3
|
-
import { mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs'
|
|
3
|
+
import { mkdirSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import type { DocEnvelope, RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc, ErrorDoc } from '../implementations/types.js'
|
|
7
|
+
import { runTsc } from './test-helpers/run-tsc.js'
|
|
7
8
|
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Fixtures
|
|
@@ -464,28 +465,20 @@ describe('E2E: generateClient full pipeline', () => {
|
|
|
464
465
|
tmpDir = makeTmpDir()
|
|
465
466
|
await generateClient({ envelope, outDir: tmpDir, selfContained: true })
|
|
466
467
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
468
|
+
runTsc({
|
|
469
|
+
tmpDir,
|
|
470
|
+
tsconfigInline: {
|
|
471
|
+
compilerOptions: {
|
|
472
|
+
strict: true,
|
|
473
|
+
target: 'ES2022',
|
|
474
|
+
module: 'ES2022',
|
|
475
|
+
moduleResolution: 'bundler',
|
|
476
|
+
noEmit: true,
|
|
477
|
+
skipLibCheck: true,
|
|
478
|
+
},
|
|
479
|
+
include: ['_types.ts', '_client.ts'],
|
|
476
480
|
},
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
const { writeFileSync } = await import('node:fs')
|
|
480
|
-
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
|
|
481
|
-
|
|
482
|
-
const { execSync } = await import('node:child_process')
|
|
483
|
-
// Use the project's tsc binary directly (temp dir has no node_modules)
|
|
484
|
-
const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
|
|
485
|
-
// tsc --noEmit --project tsconfig.json should succeed with exit code 0
|
|
486
|
-
expect(() => {
|
|
487
|
-
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
488
|
-
}).not.toThrow()
|
|
481
|
+
})
|
|
489
482
|
})
|
|
490
483
|
|
|
491
484
|
it('_types.ts exports RequestMeta (empty interface ready for augmentation)', async () => {
|
|
@@ -562,27 +555,22 @@ async function run(): Promise<void> {
|
|
|
562
555
|
}
|
|
563
556
|
void run
|
|
564
557
|
`
|
|
565
|
-
const { writeFileSync } = await import('node:fs')
|
|
566
558
|
writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
|
|
567
559
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
560
|
+
runTsc({
|
|
561
|
+
tmpDir,
|
|
562
|
+
tsconfigInline: {
|
|
563
|
+
compilerOptions: {
|
|
564
|
+
strict: true,
|
|
565
|
+
target: 'ES2022',
|
|
566
|
+
module: 'ES2022',
|
|
567
|
+
moduleResolution: 'bundler',
|
|
568
|
+
noEmit: true,
|
|
569
|
+
skipLibCheck: true,
|
|
570
|
+
},
|
|
571
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
|
|
576
572
|
},
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
|
|
580
|
-
|
|
581
|
-
const { execSync } = await import('node:child_process')
|
|
582
|
-
const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
|
|
583
|
-
expect(() => {
|
|
584
|
-
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
585
|
-
}).not.toThrow()
|
|
573
|
+
})
|
|
586
574
|
})
|
|
587
575
|
|
|
588
576
|
it('generated .safe callable type-checks against bundled Result/ResultNoTyped types', async () => {
|
|
@@ -668,27 +656,22 @@ const _p3 = client.users.ListUsers.safe({})
|
|
|
668
656
|
const _p3check: Promise<ResultNoTyped<unknown>> = _p3 as Promise<ResultNoTyped<unknown>>
|
|
669
657
|
void _p3check
|
|
670
658
|
`
|
|
671
|
-
const { writeFileSync } = await import('node:fs')
|
|
672
659
|
writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
|
|
673
660
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
661
|
+
runTsc({
|
|
662
|
+
tmpDir,
|
|
663
|
+
tsconfigInline: {
|
|
664
|
+
compilerOptions: {
|
|
665
|
+
strict: true,
|
|
666
|
+
target: 'ES2022',
|
|
667
|
+
module: 'ES2022',
|
|
668
|
+
moduleResolution: 'bundler',
|
|
669
|
+
noEmit: true,
|
|
670
|
+
skipLibCheck: true,
|
|
671
|
+
},
|
|
672
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', '_errors.ts', 'consumer.ts'],
|
|
682
673
|
},
|
|
683
|
-
|
|
684
|
-
}
|
|
685
|
-
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
|
|
686
|
-
|
|
687
|
-
const { execSync } = await import('node:child_process')
|
|
688
|
-
const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
|
|
689
|
-
expect(() => {
|
|
690
|
-
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
691
|
-
}).not.toThrow()
|
|
674
|
+
})
|
|
692
675
|
})
|
|
693
676
|
|
|
694
677
|
it('augmented RequestMeta rejects wrong types (compile error)', async () => {
|
|
@@ -720,29 +703,25 @@ async function run(): Promise<void> {
|
|
|
720
703
|
}
|
|
721
704
|
void run
|
|
722
705
|
`
|
|
723
|
-
const { writeFileSync } = await import('node:fs')
|
|
724
706
|
writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
|
|
725
707
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
708
|
+
// With `@ts-expect-error` in place, tsc should pass; if RequestMeta
|
|
709
|
+
// weren't enforcing the type, `@ts-expect-error` would itself fail
|
|
710
|
+
// (because there'd be no error to suppress).
|
|
711
|
+
runTsc({
|
|
712
|
+
tmpDir,
|
|
713
|
+
tsconfigInline: {
|
|
714
|
+
compilerOptions: {
|
|
715
|
+
strict: true,
|
|
716
|
+
target: 'ES2022',
|
|
717
|
+
module: 'ES2022',
|
|
718
|
+
moduleResolution: 'bundler',
|
|
719
|
+
noEmit: true,
|
|
720
|
+
skipLibCheck: true,
|
|
721
|
+
},
|
|
722
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
|
|
734
723
|
},
|
|
735
|
-
|
|
736
|
-
}
|
|
737
|
-
writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
|
|
738
|
-
|
|
739
|
-
const { execSync } = await import('node:child_process')
|
|
740
|
-
const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
|
|
741
|
-
// With @ts-expect-error in place, tsc should pass; if RequestMeta wasn't
|
|
742
|
-
// enforcing the type, @ts-expect-error would fail because there'd be no error.
|
|
743
|
-
expect(() => {
|
|
744
|
-
execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
745
|
-
}).not.toThrow()
|
|
724
|
+
})
|
|
746
725
|
})
|
|
747
726
|
})
|
|
748
727
|
|
|
@@ -833,4 +812,47 @@ void run
|
|
|
833
812
|
expect(content).toContain('export enum Status')
|
|
834
813
|
})
|
|
835
814
|
})
|
|
815
|
+
|
|
816
|
+
// ── verbatimModuleSyntax compatibility ─────────────────────────────────────
|
|
817
|
+
//
|
|
818
|
+
// Bug repro (downstream): generated client fails to compile under
|
|
819
|
+
// tsconfigs/strict (which enables `verbatimModuleSyntax: true`) with errors
|
|
820
|
+
// like:
|
|
821
|
+
// error TS1659: 'export import' is not allowed in a 'declaration' file.
|
|
822
|
+
// error TS1287: A type-only import can specify a default import or named
|
|
823
|
+
// bindings, but not both.
|
|
824
|
+
//
|
|
825
|
+
// The current emitIndexFile uses `export import X = _x.X` to re-export each
|
|
826
|
+
// scope namespace from inside `export namespace Cp { ... }`. This namespace
|
|
827
|
+
// alias syntax is incompatible with `verbatimModuleSyntax`. The test below
|
|
828
|
+
// generates a client and compiles it under that strict mode — no errors.
|
|
829
|
+
|
|
830
|
+
describe('verbatimModuleSyntax compatibility (bug repro)', () => {
|
|
831
|
+
it('generated self-contained client compiles cleanly under verbatimModuleSyntax: true with namespaceTypes', async () => {
|
|
832
|
+
tmpDir = makeTmpDir()
|
|
833
|
+
await generateClient({
|
|
834
|
+
envelope,
|
|
835
|
+
outDir: tmpDir,
|
|
836
|
+
selfContained: true,
|
|
837
|
+
namespaceTypes: true,
|
|
838
|
+
serviceName: 'Cp',
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
runTsc({
|
|
842
|
+
tmpDir,
|
|
843
|
+
tsconfigInline: {
|
|
844
|
+
compilerOptions: {
|
|
845
|
+
strict: true,
|
|
846
|
+
target: 'ES2022',
|
|
847
|
+
module: 'ES2022',
|
|
848
|
+
moduleResolution: 'bundler',
|
|
849
|
+
verbatimModuleSyntax: true,
|
|
850
|
+
noEmit: true,
|
|
851
|
+
skipLibCheck: true,
|
|
852
|
+
},
|
|
853
|
+
include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts'],
|
|
854
|
+
},
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
})
|
|
836
858
|
})
|
|
@@ -77,8 +77,18 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
|
|
|
77
77
|
].join('\n')
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// Namespace mode puts `bindScope` inside the `${Pascal}` namespace
|
|
81
|
+
// (`Users.bindScope`); flat mode keeps it as a standalone export
|
|
82
|
+
// (`bindUsersScope`). Both compile cleanly — the branch picks the right
|
|
83
|
+
// call shape for the emitted scope file.
|
|
80
84
|
const scopeBindings = groups
|
|
81
|
-
.map((g) =>
|
|
85
|
+
.map((g) => {
|
|
86
|
+
const pascal = toPascalCase(g.camelCase)
|
|
87
|
+
const bindCall = namespaceTypes
|
|
88
|
+
? `${localAlias(g.camelCase)}.${pascal}.bindScope(client)`
|
|
89
|
+
: `${localAlias(g.camelCase)}.bind${pascal}Scope(client)`
|
|
90
|
+
return ` ${g.camelCase}: ${bindCall},`
|
|
91
|
+
})
|
|
82
92
|
.join('\n')
|
|
83
93
|
|
|
84
94
|
const pieces: string[] = [
|
|
@@ -431,9 +431,14 @@ describe('emitScopeFile', () => {
|
|
|
431
431
|
expect(output).toContain('client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>')
|
|
432
432
|
})
|
|
433
433
|
|
|
434
|
-
it('
|
|
434
|
+
it('emits bindScope as a member of the scope namespace (so the namespace is value+type)', async () => {
|
|
435
435
|
const output = await emitScopeFile(rpcGroup, { namespaceTypes: true })
|
|
436
|
-
|
|
436
|
+
// In namespace mode, `bindScope` lives inside the `${pascal}` namespace
|
|
437
|
+
// (`Users.bindScope`) — making the merged symbol value+type so index.ts
|
|
438
|
+
// can `export import` it under verbatimModuleSyntax. The standalone
|
|
439
|
+
// `bindUsersScope` is reserved for flat mode only.
|
|
440
|
+
expect(output).toMatch(/export namespace Users \{[\s\S]*export function bindScope\(client: ClientInstance\)/)
|
|
441
|
+
expect(output).not.toContain('export function bindUsersScope')
|
|
437
442
|
})
|
|
438
443
|
})
|
|
439
444
|
|
|
@@ -901,6 +906,171 @@ describe('emitScopeFile .safe sibling on API', () => {
|
|
|
901
906
|
// Stream callables omit .safe sibling (regression guard for Task 14)
|
|
902
907
|
// ---------------------------------------------------------------------------
|
|
903
908
|
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
// Bug repro (downstream): duplicate Scope / Params identifiers in scope namespaces
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
//
|
|
913
|
+
// Reported as "Duplicate Scope / Params identifiers in keys.ts / schemas.ts".
|
|
914
|
+
// Root cause: ajsc with inlineTypes:false extracts a property named `params`
|
|
915
|
+
// (or `scope`) into a sub-type literally named `Params` (or `Scope`). For RPC
|
|
916
|
+
// routes, formatTypes also adds `export type Params = <body>` for the route's
|
|
917
|
+
// own params channel — collision. For API routes, emitApiRoute injects an
|
|
918
|
+
// additional `export type Params = { ... }` — same collision. Either way,
|
|
919
|
+
// the resulting namespace has two `export type Params` lines and won't compile.
|
|
920
|
+
|
|
921
|
+
const rpcGroupParamsCollision: ScopeGroup = {
|
|
922
|
+
scopeKey: 'schemas',
|
|
923
|
+
camelCase: 'schemas',
|
|
924
|
+
routes: [
|
|
925
|
+
{
|
|
926
|
+
kind: 'rpc',
|
|
927
|
+
name: 'CreateSchema',
|
|
928
|
+
path: '/schemas',
|
|
929
|
+
method: 'post',
|
|
930
|
+
scope: 'schemas',
|
|
931
|
+
version: 1,
|
|
932
|
+
jsonSchema: {
|
|
933
|
+
// The body has a property literally named `params` whose value is an
|
|
934
|
+
// object — ajsc with inlineTypes:false will extract `export type Params = {...}`.
|
|
935
|
+
body: {
|
|
936
|
+
type: 'object',
|
|
937
|
+
properties: {
|
|
938
|
+
params: {
|
|
939
|
+
type: 'object',
|
|
940
|
+
properties: { kind: { type: 'string' }, value: { type: 'string' } },
|
|
941
|
+
required: ['kind'],
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
required: ['params'],
|
|
945
|
+
},
|
|
946
|
+
response: {
|
|
947
|
+
type: 'object',
|
|
948
|
+
properties: { id: { type: 'string' } },
|
|
949
|
+
required: ['id'],
|
|
950
|
+
},
|
|
951
|
+
},
|
|
952
|
+
} satisfies RPCHttpRouteDoc,
|
|
953
|
+
],
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const apiGroupScopeCollision: ScopeGroup = {
|
|
957
|
+
scopeKey: 'keys',
|
|
958
|
+
camelCase: 'keys',
|
|
959
|
+
routes: [
|
|
960
|
+
{
|
|
961
|
+
kind: 'api',
|
|
962
|
+
name: 'CreateKey',
|
|
963
|
+
path: '/keys',
|
|
964
|
+
method: 'post',
|
|
965
|
+
fullPath: '/api/keys',
|
|
966
|
+
scope: 'keys',
|
|
967
|
+
jsonSchema: {
|
|
968
|
+
// Body has a property literally named `scope` whose value is an object —
|
|
969
|
+
// ajsc with inlineTypes:false extracts `export type Scope = {...}`.
|
|
970
|
+
body: {
|
|
971
|
+
type: 'object',
|
|
972
|
+
properties: {
|
|
973
|
+
scope: {
|
|
974
|
+
type: 'object',
|
|
975
|
+
properties: { resource: { type: 'string' }, action: { type: 'string' } },
|
|
976
|
+
required: ['resource', 'action'],
|
|
977
|
+
},
|
|
978
|
+
},
|
|
979
|
+
required: ['scope'],
|
|
980
|
+
},
|
|
981
|
+
// Response also contains a property named `scope`, but with a DIFFERENT
|
|
982
|
+
// shape (additional `grantedAt` field). ajsc extracts a second
|
|
983
|
+
// `export type Scope = {...}` whose body string differs from the body's
|
|
984
|
+
// `Scope`, so the dedup-by-string-equality in formatTypes does not
|
|
985
|
+
// collapse them — both end up in the namespace.
|
|
986
|
+
response: {
|
|
987
|
+
type: 'object',
|
|
988
|
+
properties: {
|
|
989
|
+
id: { type: 'string' },
|
|
990
|
+
scope: {
|
|
991
|
+
type: 'object',
|
|
992
|
+
properties: {
|
|
993
|
+
resource: { type: 'string' },
|
|
994
|
+
action: { type: 'string' },
|
|
995
|
+
grantedAt: { type: 'string' },
|
|
996
|
+
},
|
|
997
|
+
required: ['resource', 'action', 'grantedAt'],
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
required: ['id', 'scope'],
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
} satisfies APIHttpRouteDoc,
|
|
1004
|
+
],
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const apiGroupParamsCollision: ScopeGroup = {
|
|
1008
|
+
scopeKey: 'schemas',
|
|
1009
|
+
camelCase: 'schemas',
|
|
1010
|
+
routes: [
|
|
1011
|
+
{
|
|
1012
|
+
kind: 'api',
|
|
1013
|
+
name: 'RegisterSchema',
|
|
1014
|
+
path: '/schemas',
|
|
1015
|
+
method: 'post',
|
|
1016
|
+
fullPath: '/api/schemas',
|
|
1017
|
+
scope: 'schemas',
|
|
1018
|
+
jsonSchema: {
|
|
1019
|
+
// Body has a property literally named `params` — ajsc extracts
|
|
1020
|
+
// `export type Params = {...}`. emitApiRoute then injects an additional
|
|
1021
|
+
// `export type Params = { body: Body }` for the structured channel
|
|
1022
|
+
// params. Two `Params` declarations in the same namespace.
|
|
1023
|
+
body: {
|
|
1024
|
+
type: 'object',
|
|
1025
|
+
properties: {
|
|
1026
|
+
name: { type: 'string' },
|
|
1027
|
+
params: {
|
|
1028
|
+
type: 'object',
|
|
1029
|
+
properties: { kind: { type: 'string' } },
|
|
1030
|
+
required: ['kind'],
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
required: ['name', 'params'],
|
|
1034
|
+
},
|
|
1035
|
+
response: {
|
|
1036
|
+
type: 'object',
|
|
1037
|
+
properties: { id: { type: 'string' } },
|
|
1038
|
+
required: ['id'],
|
|
1039
|
+
},
|
|
1040
|
+
},
|
|
1041
|
+
} satisfies APIHttpRouteDoc,
|
|
1042
|
+
],
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** Counts non-overlapping occurrences of `export type ${name} =` in a string. */
|
|
1046
|
+
function countTypeDeclarations(source: string, name: string): number {
|
|
1047
|
+
const matches = source.match(new RegExp(`export\\s+type\\s+${name}\\s*=`, 'g'))
|
|
1048
|
+
return matches ? matches.length : 0
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
describe('emitScopeFile (bug repro: duplicate identifiers in namespace)', () => {
|
|
1052
|
+
it('does not emit two `export type Params` inside a route namespace when the body has a `params` property (RPC)', async () => {
|
|
1053
|
+
const out = await emitScopeFile(rpcGroupParamsCollision, { namespaceTypes: true })
|
|
1054
|
+
// The namespace is `Schemas { CreateSchema { ... } }`. There must be only
|
|
1055
|
+
// one `export type Params` inside the CreateSchema namespace.
|
|
1056
|
+
expect(countTypeDeclarations(out, 'Params')).toBe(1)
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
it('does not emit two `export type Scope` inside a route namespace when body and response have differently-shaped `scope` properties (API)', async () => {
|
|
1060
|
+
const out = await emitScopeFile(apiGroupScopeCollision, { namespaceTypes: true })
|
|
1061
|
+
// Two extracted `Scope` sub-types with differing bodies → string-equality
|
|
1062
|
+
// dedup does not collapse them, both get emitted.
|
|
1063
|
+
expect(countTypeDeclarations(out, 'Scope')).toBe(1)
|
|
1064
|
+
})
|
|
1065
|
+
|
|
1066
|
+
it('does not emit two `export type Params` inside a route namespace when the body has a `params` property (API)', async () => {
|
|
1067
|
+
const out = await emitScopeFile(apiGroupParamsCollision, { namespaceTypes: true })
|
|
1068
|
+
// One extracted `Params` (from body.params property) + one injected `Params`
|
|
1069
|
+
// (the structured channel composer) → collision.
|
|
1070
|
+
expect(countTypeDeclarations(out, 'Params')).toBe(1)
|
|
1071
|
+
})
|
|
1072
|
+
})
|
|
1073
|
+
|
|
904
1074
|
describe('emitScopeFile streams omit .safe sibling', () => {
|
|
905
1075
|
it('does not emit .safe on stream callables', async () => {
|
|
906
1076
|
const out = await emitScopeFile(streamGroup, {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
jsonSchemaToTypeString,
|
|
9
9
|
jsonSchemaToTypeBody,
|
|
10
10
|
jsonSchemaToExtractedTypes,
|
|
11
|
+
renameExtractedTypes,
|
|
11
12
|
type AjscOptions,
|
|
12
13
|
type ExtractedTypeOutput,
|
|
13
14
|
} from './emit-types.js'
|
|
@@ -124,11 +125,17 @@ interface FormattedTypes {
|
|
|
124
125
|
* Converts multiple schemas into type declarations and type references.
|
|
125
126
|
* In flat mode: `export type ${routePascal}${shortName} = <body>`
|
|
126
127
|
* In namespace mode: extracted sub-types + named types inside `export namespace ${routePascal} { ... }`
|
|
128
|
+
*
|
|
129
|
+
* `extraReserved` lets the caller pre-reserve identifier names that the route
|
|
130
|
+
* will inject AFTER formatTypes returns (e.g. emitApiRoute's structured
|
|
131
|
+
* `Params`). Without this, an ajsc-extracted sub-type could shadow the
|
|
132
|
+
* injected one and produce duplicate `export type Params` declarations.
|
|
127
133
|
*/
|
|
128
134
|
async function formatTypes(
|
|
129
135
|
routePascal: string,
|
|
130
136
|
types: NamedType[],
|
|
131
137
|
ctx: EmitRouteContext,
|
|
138
|
+
extraReserved?: ReadonlySet<string>,
|
|
132
139
|
): Promise<FormattedTypes> {
|
|
133
140
|
const declarations: string[] = []
|
|
134
141
|
const refs: Record<string, string> = {}
|
|
@@ -137,13 +144,26 @@ async function formatTypes(
|
|
|
137
144
|
const nsLines: string[] = []
|
|
138
145
|
const seenDeclarations = new Set<string>()
|
|
139
146
|
|
|
147
|
+
// Pre-reserve every name the route will declare itself (each shortName +
|
|
148
|
+
// any caller-supplied extras). Extracted sub-types whose names land in
|
|
149
|
+
// this set get renamed (e.g. `Params` → `Params_`) by renameExtractedTypes,
|
|
150
|
+
// and the body string is patched in lockstep so the reference still
|
|
151
|
+
// resolves. The set is mutated as we go, so a sub-type renamed in schema A
|
|
152
|
+
// is also reserved against schema B.
|
|
153
|
+
const taken = new Set<string>(extraReserved ?? [])
|
|
154
|
+
for (const t of types) {
|
|
155
|
+
if (t.schema != null) taken.add(t.shortName)
|
|
156
|
+
}
|
|
157
|
+
|
|
140
158
|
for (const { shortName, schema } of types) {
|
|
141
159
|
if (schema == null) continue
|
|
142
160
|
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
161
|
+
const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
|
|
162
|
+
if (rawResult == null) continue
|
|
145
163
|
|
|
146
|
-
|
|
164
|
+
const result = renameExtractedTypes(rawResult, taken)
|
|
165
|
+
|
|
166
|
+
// Collect extracted sub-types (deduplicate across schemas by exact-string)
|
|
147
167
|
for (const decl of result.declarations) {
|
|
148
168
|
if (!seenDeclarations.has(decl)) {
|
|
149
169
|
seenDeclarations.add(decl)
|
|
@@ -290,7 +310,14 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
|
|
|
290
310
|
// Add response
|
|
291
311
|
channelTypes.push({ shortName: 'Response', schema: route.jsonSchema.response })
|
|
292
312
|
|
|
293
|
-
|
|
313
|
+
// `Params` is injected into the namespace below (after formatTypes), so
|
|
314
|
+
// reserve it up-front to keep ajsc-extracted sub-types from shadowing it.
|
|
315
|
+
const { declarations, refs } = await formatTypes(
|
|
316
|
+
pascal,
|
|
317
|
+
channelTypes,
|
|
318
|
+
ctx,
|
|
319
|
+
new Set(['Params']),
|
|
320
|
+
)
|
|
294
321
|
|
|
295
322
|
// Compose structured Params type from channels
|
|
296
323
|
let paramsTypeName = 'unknown'
|
|
@@ -479,17 +506,43 @@ export async function emitScopeFile(
|
|
|
479
506
|
|
|
480
507
|
const importsBlock = [clientImports, errorsImport].filter(Boolean).join('\n')
|
|
481
508
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
509
|
+
const callablesBlock = callables.join('\n\n')
|
|
510
|
+
|
|
511
|
+
if (namespaceTypes) {
|
|
512
|
+
// Namespace mode: types AND `bindScope` live inside `export namespace ${pascal}`.
|
|
513
|
+
// Putting the function inside the namespace makes the merged symbol
|
|
514
|
+
// value+type, which lets `index.ts` use `export import ${pascal} = …`
|
|
515
|
+
// under `verbatimModuleSyntax: true`. (A type-only namespace would trip
|
|
516
|
+
// TS1269/TS1288.) Consumers call `${Pascal}.bindScope(client)`; the
|
|
517
|
+
// generated `index.ts` factory wires this internally.
|
|
518
|
+
const callableLines = indent(callablesBlock, ' ')
|
|
519
|
+
const namespaceMembers = [
|
|
520
|
+
...(allTypeDeclarations.length > 0 ? [allTypeDeclarations.join('\n\n')] : []),
|
|
521
|
+
[
|
|
522
|
+
' /** Binds every callable in this scope to a configured client. */',
|
|
523
|
+
' export function bindScope(client: ClientInstance) {',
|
|
524
|
+
' return {',
|
|
525
|
+
callableLines,
|
|
526
|
+
' }',
|
|
527
|
+
' }',
|
|
528
|
+
].join('\n'),
|
|
529
|
+
].join('\n\n')
|
|
530
|
+
|
|
531
|
+
return [
|
|
532
|
+
CODEGEN_HEADER,
|
|
533
|
+
importsBlock,
|
|
534
|
+
'',
|
|
535
|
+
`export namespace ${pascal} {`,
|
|
536
|
+
namespaceMembers,
|
|
537
|
+
'}',
|
|
538
|
+
'',
|
|
539
|
+
].join('\n')
|
|
490
540
|
}
|
|
491
541
|
|
|
492
|
-
|
|
542
|
+
// Flat mode: types at module level, `bind${pascal}Scope` standalone.
|
|
543
|
+
const typesBlock = allTypeDeclarations.length > 0
|
|
544
|
+
? allTypeDeclarations.join('\n') + '\n'
|
|
545
|
+
: ''
|
|
493
546
|
|
|
494
547
|
return [
|
|
495
548
|
CODEGEN_HEADER,
|