ts-procedures 7.0.0 → 7.1.1

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 (80) 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/client/index.js +5 -0
  5. package/build/client/index.js.map +1 -1
  6. package/build/client/stream.d.ts +25 -1
  7. package/build/client/stream.js +48 -5
  8. package/build/client/stream.js.map +1 -1
  9. package/build/client/stream.test.js +68 -1
  10. package/build/client/stream.test.js.map +1 -1
  11. package/build/codegen/bin/cli.js +91 -0
  12. package/build/codegen/bin/cli.js.map +1 -1
  13. package/build/codegen/bin/cli.test.js +15 -0
  14. package/build/codegen/bin/cli.test.js.map +1 -1
  15. package/build/codegen/e2e.test.js +97 -74
  16. package/build/codegen/e2e.test.js.map +1 -1
  17. package/build/codegen/emit-index.js +11 -1
  18. package/build/codegen/emit-index.js.map +1 -1
  19. package/build/codegen/emit-scope.js +58 -16
  20. package/build/codegen/emit-scope.js.map +1 -1
  21. package/build/codegen/emit-scope.test.js +164 -2
  22. package/build/codegen/emit-scope.test.js.map +1 -1
  23. package/build/codegen/emit-types.d.ts +28 -0
  24. package/build/codegen/emit-types.js +69 -5
  25. package/build/codegen/emit-types.js.map +1 -1
  26. package/build/codegen/emit-types.test.js +30 -0
  27. package/build/codegen/emit-types.test.js.map +1 -1
  28. package/build/codegen/resolve-envelope.js +4 -1
  29. package/build/codegen/resolve-envelope.js.map +1 -1
  30. package/build/codegen/resolve-envelope.test.js +10 -0
  31. package/build/codegen/resolve-envelope.test.js.map +1 -1
  32. package/build/codegen/test-helpers/run-tsc.d.ts +33 -0
  33. package/build/codegen/test-helpers/run-tsc.js +49 -0
  34. package/build/codegen/test-helpers/run-tsc.js.map +1 -0
  35. package/build/implementations/http/doc-registry.js +14 -0
  36. package/build/implementations/http/doc-registry.js.map +1 -1
  37. package/build/implementations/http/doc-registry.test.js +37 -1
  38. package/build/implementations/http/doc-registry.test.js.map +1 -1
  39. package/build/implementations/http/hono-rpc/index.d.ts +11 -0
  40. package/build/implementations/http/hono-rpc/index.js +22 -1
  41. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  42. package/build/implementations/http/hono-rpc/index.test.js +25 -0
  43. package/build/implementations/http/hono-rpc/index.test.js.map +1 -1
  44. package/build/implementations/http/hono-stream/error-taxonomy.test.js +72 -0
  45. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -1
  46. package/build/implementations/http/hono-stream/index.d.ts +18 -4
  47. package/build/implementations/http/hono-stream/index.js +97 -18
  48. package/build/implementations/http/hono-stream/index.js.map +1 -1
  49. package/build/implementations/http/hono-stream/index.test.js +3 -3
  50. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  51. package/build/implementations/types.d.ts +10 -0
  52. package/build/index.js +22 -17
  53. package/build/index.js.map +1 -1
  54. package/build/index.test.js +36 -6
  55. package/build/index.test.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/client/index.ts +6 -0
  58. package/src/client/stream.test.ts +82 -1
  59. package/src/client/stream.ts +67 -4
  60. package/src/codegen/bin/cli.test.ts +26 -0
  61. package/src/codegen/bin/cli.ts +91 -0
  62. package/src/codegen/e2e.test.ts +100 -78
  63. package/src/codegen/emit-index.ts +11 -1
  64. package/src/codegen/emit-scope.test.ts +172 -2
  65. package/src/codegen/emit-scope.ts +66 -13
  66. package/src/codegen/emit-types.test.ts +34 -0
  67. package/src/codegen/emit-types.ts +83 -5
  68. package/src/codegen/resolve-envelope.test.ts +11 -0
  69. package/src/codegen/resolve-envelope.ts +4 -1
  70. package/src/codegen/test-helpers/run-tsc.ts +56 -0
  71. package/src/implementations/http/doc-registry.test.ts +43 -1
  72. package/src/implementations/http/doc-registry.ts +19 -0
  73. package/src/implementations/http/hono-rpc/index.test.ts +32 -0
  74. package/src/implementations/http/hono-rpc/index.ts +27 -1
  75. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +80 -0
  76. package/src/implementations/http/hono-stream/index.test.ts +3 -3
  77. package/src/implementations/http/hono-stream/index.ts +118 -22
  78. package/src/implementations/types.ts +7 -0
  79. package/src/index.test.ts +43 -6
  80. package/src/index.ts +23 -20
@@ -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,
@@ -178,6 +178,40 @@ describe('jsonSchemaToExtractedTypes', () => {
178
178
  expect(result!.body).not.toContain('export type Root')
179
179
  expect(result!.body).toContain('id: string')
180
180
  })
181
+
182
+ // Bug repro (downstream): codegen emits `Cannot find name 'RootType'`.
183
+ // ajsc with inlineTypes:false renders an array-root schema as TWO blocks:
184
+ // export type RootType = { ... };
185
+ // export type Root = Array<RootType>;
186
+ // The current parser uses `block.startsWith('export type Root')` which
187
+ // matches BOTH `Root` and `RootType`, swallowing the `RootType` declaration
188
+ // and leaving the body's `Array<RootType>` reference dangling.
189
+ it('preserves the RootType items declaration when the root schema is an array of objects', async () => {
190
+ const schema = {
191
+ type: 'array',
192
+ items: {
193
+ type: 'object',
194
+ properties: {
195
+ id: { type: 'string' },
196
+ name: { type: 'string' },
197
+ },
198
+ required: ['id', 'name'],
199
+ },
200
+ }
201
+ const result = await jsonSchemaToExtractedTypes(schema)
202
+ expect(result).not.toBeUndefined()
203
+ // The body should be `Array<RootType>` (or equivalent referencing a named items type)
204
+ expect(result!.body).toMatch(/^Array<\w+>$/)
205
+
206
+ // The items type must be present in declarations so the body reference resolves.
207
+ // Extract the referenced name from the body and assert a matching declaration exists.
208
+ const refName = result!.body.match(/^Array<(\w+)>$/)?.[1]
209
+ expect(refName).toBeDefined()
210
+ const hasItemsDecl = result!.declarations.some((d) =>
211
+ new RegExp(`^export\\s+type\\s+${refName}\\s*=`).test(d)
212
+ )
213
+ expect(hasItemsDecl).toBe(true)
214
+ })
181
215
  })
182
216
 
183
217
  describe('jsonSchemaToTypeString (prefix stripping)', () => {