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.
Files changed (39) hide show
  1. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +4 -0
  2. package/agent_config/copilot/copilot-instructions.md +2 -0
  3. package/agent_config/cursor/cursorrules +2 -0
  4. package/build/codegen/bin/cli.js +91 -0
  5. package/build/codegen/bin/cli.js.map +1 -1
  6. package/build/codegen/bin/cli.test.js +15 -0
  7. package/build/codegen/bin/cli.test.js.map +1 -1
  8. package/build/codegen/e2e.test.js +97 -74
  9. package/build/codegen/e2e.test.js.map +1 -1
  10. package/build/codegen/emit-index.js +11 -1
  11. package/build/codegen/emit-index.js.map +1 -1
  12. package/build/codegen/emit-scope.js +58 -16
  13. package/build/codegen/emit-scope.js.map +1 -1
  14. package/build/codegen/emit-scope.test.js +164 -2
  15. package/build/codegen/emit-scope.test.js.map +1 -1
  16. package/build/codegen/emit-types.d.ts +28 -0
  17. package/build/codegen/emit-types.js +69 -5
  18. package/build/codegen/emit-types.js.map +1 -1
  19. package/build/codegen/emit-types.test.js +30 -0
  20. package/build/codegen/emit-types.test.js.map +1 -1
  21. package/build/codegen/resolve-envelope.js +4 -1
  22. package/build/codegen/resolve-envelope.js.map +1 -1
  23. package/build/codegen/resolve-envelope.test.js +10 -0
  24. package/build/codegen/resolve-envelope.test.js.map +1 -1
  25. package/build/codegen/test-helpers/run-tsc.d.ts +33 -0
  26. package/build/codegen/test-helpers/run-tsc.js +49 -0
  27. package/build/codegen/test-helpers/run-tsc.js.map +1 -0
  28. package/package.json +1 -1
  29. package/src/codegen/bin/cli.test.ts +26 -0
  30. package/src/codegen/bin/cli.ts +91 -0
  31. package/src/codegen/e2e.test.ts +100 -78
  32. package/src/codegen/emit-index.ts +11 -1
  33. package/src/codegen/emit-scope.test.ts +172 -2
  34. package/src/codegen/emit-scope.ts +66 -13
  35. package/src/codegen/emit-types.test.ts +34 -0
  36. package/src/codegen/emit-types.ts +83 -5
  37. package/src/codegen/resolve-envelope.test.ts +11 -0
  38. package/src/codegen/resolve-envelope.ts +4 -1
  39. package/src/codegen/test-helpers/run-tsc.ts +56 -0
@@ -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
 
@@ -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
- // Write a minimal tsconfig for the generated files
468
- const tsconfig = {
469
- compilerOptions: {
470
- strict: true,
471
- target: 'ES2022',
472
- module: 'ES2022',
473
- moduleResolution: 'bundler',
474
- noEmit: true,
475
- skipLibCheck: true,
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
- include: ['_types.ts', '_client.ts'],
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
- const tsconfig = {
569
- compilerOptions: {
570
- strict: true,
571
- target: 'ES2022',
572
- module: 'ES2022',
573
- moduleResolution: 'bundler',
574
- noEmit: true,
575
- skipLibCheck: true,
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
- include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
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
- const tsconfig = {
675
- compilerOptions: {
676
- strict: true,
677
- target: 'ES2022',
678
- module: 'ES2022',
679
- moduleResolution: 'bundler',
680
- noEmit: true,
681
- skipLibCheck: true,
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
- include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', '_errors.ts', 'consumer.ts'],
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
- const tsconfig = {
727
- compilerOptions: {
728
- strict: true,
729
- target: 'ES2022',
730
- module: 'ES2022',
731
- moduleResolution: 'bundler',
732
- noEmit: true,
733
- skipLibCheck: true,
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
- include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', 'events.ts', '_errors.ts', 'consumer.ts'],
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) => ` ${g.camelCase}: ${localAlias(g.camelCase)}.bind${toPascalCase(g.camelCase)}Scope(client),`)
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('still emits the bind function', async () => {
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
- expect(output).toContain('export function bindUsersScope(client: ClientInstance)')
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 result = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
144
- if (result == null) continue
161
+ const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
162
+ if (rawResult == null) continue
145
163
 
146
- // Collect extracted sub-types (deduplicate across schemas)
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
- const { declarations, refs } = await formatTypes(pascal, channelTypes, ctx)
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
- let typesBlock: string
483
- if (namespaceTypes && allTypeDeclarations.length > 0) {
484
- typesBlock = `export namespace ${pascal} {\n${allTypeDeclarations.join('\n\n')}\n}\n`
485
- } else {
486
- typesBlock =
487
- allTypeDeclarations.length > 0
488
- ? allTypeDeclarations.join('\n') + '\n'
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
- const callablesBlock = callables.join('\n\n')
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,