ts-procedures 8.0.0 → 8.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.
@@ -5,6 +5,7 @@ import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import type { DocEnvelope, RPCHttpRouteDoc, APIHttpRouteDoc, StreamHttpRouteDoc, HttpStreamRouteDoc, ErrorDoc } from '../implementations/types.js'
7
7
  import { runTsc } from './test-helpers/run-tsc.js'
8
+ import { Type } from 'typebox'
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Fixtures
@@ -929,4 +930,357 @@ void run
929
930
  })
930
931
  })
931
932
  })
933
+
934
+ // ── Duplicate-identifier bug: type shared between params and returnType ──────
935
+ //
936
+ // End-to-end gold-standard guards. ajsc glues sibling extracted sub-types
937
+ // with a single "\n"; emit-types only renames/dedups the first one, so a
938
+ // route whose body and response both yield >=2 sub-types emits the non-first
939
+ // sub-type twice -> `error TS2300: Duplicate identifier`. These compile the
940
+ // real generated output with tsc.
941
+ describe('shared nested types across body/response', () => {
942
+ const contactShape = {
943
+ type: 'object',
944
+ required: ['name', 'address'],
945
+ properties: {
946
+ name: { type: 'string' },
947
+ address: {
948
+ type: 'object',
949
+ required: ['street', 'city'],
950
+ properties: { street: { type: 'string' }, city: { type: 'string' } },
951
+ },
952
+ },
953
+ }
954
+
955
+ const sharedNestedEnvelope: DocEnvelope = {
956
+ basePath: '/api',
957
+ headers: [],
958
+ errors: [],
959
+ routes: [
960
+ {
961
+ kind: 'rpc',
962
+ name: 'SaveContact',
963
+ path: '/contacts/save/1',
964
+ method: 'post',
965
+ scope: 'contacts',
966
+ version: 1,
967
+ jsonSchema: {
968
+ body: { type: 'object', required: ['contact'], properties: { contact: contactShape } },
969
+ response: {
970
+ type: 'object',
971
+ required: ['contact', 'savedAt'],
972
+ properties: { savedAt: { type: 'string' }, contact: contactShape },
973
+ },
974
+ },
975
+ } satisfies RPCHttpRouteDoc,
976
+ ],
977
+ }
978
+
979
+ it('generated client compiles when a nested object is shared between body and response', async () => {
980
+ tmpDir = makeTmpDir()
981
+ await generateClient({
982
+ envelope: sharedNestedEnvelope,
983
+ outDir: tmpDir,
984
+ selfContained: true,
985
+ namespaceTypes: true,
986
+ })
987
+
988
+ runTsc({
989
+ tmpDir,
990
+ tsconfigInline: {
991
+ compilerOptions: {
992
+ strict: true,
993
+ target: 'ES2022',
994
+ module: 'ES2022',
995
+ moduleResolution: 'bundler',
996
+ verbatimModuleSyntax: true,
997
+ noEmit: true,
998
+ skipLibCheck: true,
999
+ },
1000
+ include: ['_types.ts', '_client.ts', 'index.ts', 'contacts.ts', '_errors.ts'],
1001
+ },
1002
+ })
1003
+ })
1004
+
1005
+ // Latent correctness guard: same property name (`detail`), DIFFERENT shape
1006
+ // in body vs response. If the rename leaves the response's sub-type pointing
1007
+ // at the body's `Detail`, a consumer that reads the response-only field fails
1008
+ // to compile — catching the silent wrong-reference bug that TS2300 hides when
1009
+ // the shapes happen to be structurally identical.
1010
+ const divergentEnvelope: DocEnvelope = {
1011
+ basePath: '/api',
1012
+ headers: [],
1013
+ errors: [],
1014
+ routes: [
1015
+ {
1016
+ kind: 'rpc',
1017
+ name: 'SyncData',
1018
+ path: '/sync/1',
1019
+ method: 'post',
1020
+ scope: 'sync',
1021
+ version: 1,
1022
+ jsonSchema: {
1023
+ body: {
1024
+ type: 'object',
1025
+ required: ['outer'],
1026
+ properties: {
1027
+ outer: {
1028
+ type: 'object',
1029
+ required: ['detail'],
1030
+ properties: {
1031
+ detail: {
1032
+ type: 'object',
1033
+ required: ['fromClient'],
1034
+ properties: { fromClient: { type: 'string' } },
1035
+ },
1036
+ },
1037
+ },
1038
+ },
1039
+ },
1040
+ response: {
1041
+ type: 'object',
1042
+ required: ['outer'],
1043
+ properties: {
1044
+ outer: {
1045
+ type: 'object',
1046
+ required: ['detail'],
1047
+ properties: {
1048
+ detail: {
1049
+ type: 'object',
1050
+ required: ['fromServer'],
1051
+ properties: { fromServer: { type: 'number' } },
1052
+ },
1053
+ },
1054
+ },
1055
+ },
1056
+ },
1057
+ },
1058
+ } satisfies RPCHttpRouteDoc,
1059
+ ],
1060
+ }
1061
+
1062
+ it('response sub-type keeps its own shape when a name collides with a differently-shaped body sub-type', async () => {
1063
+ tmpDir = makeTmpDir()
1064
+ await generateClient({
1065
+ envelope: divergentEnvelope,
1066
+ outDir: tmpDir,
1067
+ selfContained: true,
1068
+ namespaceTypes: true,
1069
+ })
1070
+
1071
+ // Consumer asserts the response's `detail` exposes `fromServer: number`
1072
+ // (its own shape) — not the body's `{ fromClient: string }`. Imports the
1073
+ // scope file directly to validate the generated route type precisely.
1074
+ const consumer = `
1075
+ import type { Sync } from './sync'
1076
+ const r: Sync.SyncData.Response = null as any
1077
+ const n: number = r.outer.detail.fromServer
1078
+ void n
1079
+ `
1080
+ writeFileSync(join(tmpDir, '__consumer.ts'), consumer)
1081
+
1082
+ runTsc({
1083
+ tmpDir,
1084
+ tsconfigInline: {
1085
+ compilerOptions: {
1086
+ strict: true,
1087
+ target: 'ES2022',
1088
+ module: 'ES2022',
1089
+ moduleResolution: 'bundler',
1090
+ verbatimModuleSyntax: true,
1091
+ noEmit: true,
1092
+ skipLibCheck: true,
1093
+ },
1094
+ include: ['_types.ts', '_client.ts', 'index.ts', 'sync.ts', '_errors.ts', '__consumer.ts'],
1095
+ },
1096
+ })
1097
+ })
1098
+ })
1099
+
1100
+ // ── Complex TypeBox-derived schemas (broad duplication / correctness sweep) ──
1101
+ //
1102
+ // Builds realistic schemas with TypeBox, round-trips them to plain JSON Schema
1103
+ // (exactly what lands in a DocEnvelope), and stresses every known collision
1104
+ // vector at once:
1105
+ // - a deeply-nested object (Product → Manufacturer → Hq/Warehouse → Geo)
1106
+ // shared between a route's body AND response (forces the response copy to
1107
+ // rename all of its sub-types and re-point every cross-reference);
1108
+ // - the same Product reused by a SECOND route in the same scope (separate
1109
+ // route namespaces must not clash);
1110
+ // - a sub-type (Geo) referenced by two siblings (Hq + Warehouse) — the
1111
+ // rename must patch BOTH referrers;
1112
+ // - shared Filter/Range across an API route's query + body channels;
1113
+ // - a body property literally named `params`, colliding with the route's
1114
+ // own `Params` shortName;
1115
+ // - arrays-of-objects, optionals, literal unions, and records.
1116
+ //
1117
+ // Run under the default (inline-union) mode AND `enumStyle: 'enum'`, where a
1118
+ // 3-literal union explodes into three separate enums (plus ajsc's own
1119
+ // internal renaming) — the largest fused declaration block we emit. The tsc
1120
+ // compile is the authoritative duplicate-identifier (TS2300) and
1121
+ // dangling/wrong-reference guard; the consumer additionally pins deep field
1122
+ // access through the renamed types.
1123
+ describe('complex TypeBox-derived schemas', () => {
1124
+ const clean = (s: unknown): Record<string, unknown> =>
1125
+ JSON.parse(JSON.stringify(s)) as Record<string, unknown>
1126
+
1127
+ const Geo = Type.Object({ lat: Type.Number(), lng: Type.Number() })
1128
+ const Address = Type.Object({ street: Type.String(), city: Type.String(), geo: Geo })
1129
+ const Manufacturer = Type.Object({
1130
+ name: Type.String(),
1131
+ hq: Address,
1132
+ warehouses: Type.Array(Address),
1133
+ })
1134
+ const Variant = Type.Object({
1135
+ sku: Type.String(),
1136
+ price: Type.Number(),
1137
+ inStock: Type.Boolean(),
1138
+ })
1139
+ const Product = Type.Object({
1140
+ id: Type.String(),
1141
+ category: Type.Union([Type.Literal('book'), Type.Literal('food'), Type.Literal('toy')]),
1142
+ status: Type.Optional(Type.Union([Type.Literal('active'), Type.Literal('archived')])),
1143
+ dimensions: Type.Object({ w: Type.Number(), h: Type.Number(), d: Type.Number() }),
1144
+ tags: Type.Array(Type.String()),
1145
+ variants: Type.Array(Variant),
1146
+ manufacturer: Manufacturer,
1147
+ })
1148
+
1149
+ const Range = Type.Object({ min: Type.Number(), max: Type.Number() })
1150
+ const Filter = Type.Object({
1151
+ category: Type.Optional(Type.Union([Type.Literal('book'), Type.Literal('food')])),
1152
+ price: Range,
1153
+ })
1154
+ const SavedSearch = Type.Object({ name: Type.String(), filter: Filter })
1155
+
1156
+ // Route 1: rpc — body + response share Product; body also has a `params`
1157
+ // property colliding with the route's Params shortName.
1158
+ const upsertProduct: RPCHttpRouteDoc = {
1159
+ kind: 'rpc',
1160
+ name: 'UpsertProduct',
1161
+ path: '/catalog/upsert/1',
1162
+ method: 'post',
1163
+ scope: 'catalog',
1164
+ version: 1,
1165
+ jsonSchema: {
1166
+ body: clean(Type.Object({ product: Product, params: Type.Object({ dryRun: Type.Boolean() }) })),
1167
+ response: clean(
1168
+ Type.Object({
1169
+ product: Product,
1170
+ audit: Type.Object({
1171
+ revision: Type.Integer(),
1172
+ editor: Type.Object({ id: Type.String(), name: Type.String() }),
1173
+ }),
1174
+ }),
1175
+ ),
1176
+ },
1177
+ }
1178
+
1179
+ // Route 2: api — query + body share Filter/Range; res reuses Product.
1180
+ const searchCatalog: APIHttpRouteDoc = {
1181
+ kind: 'api',
1182
+ name: 'SearchCatalog',
1183
+ path: '/catalog/search',
1184
+ fullPath: '/api/catalog/search',
1185
+ method: 'post',
1186
+ scope: 'catalog',
1187
+ jsonSchema: {
1188
+ req: {
1189
+ query: clean(Type.Object({ page: Type.Integer(), filter: Type.Optional(Filter) })),
1190
+ body: clean(Type.Object({ saved: Type.Array(SavedSearch) })),
1191
+ },
1192
+ res: {
1193
+ body: clean(
1194
+ Type.Object({
1195
+ items: Type.Array(Product),
1196
+ total: Type.Integer(),
1197
+ byCategory: Type.Record(Type.String(), Type.Integer()),
1198
+ }),
1199
+ ),
1200
+ },
1201
+ },
1202
+ }
1203
+
1204
+ const complexEnvelope: DocEnvelope = {
1205
+ basePath: '/api',
1206
+ headers: [],
1207
+ errors: [],
1208
+ routes: [upsertProduct, searchCatalog],
1209
+ }
1210
+
1211
+ const consumer = `
1212
+ import type { Catalog } from './catalog'
1213
+
1214
+ // Route 1 response: deep access through the shared-and-renamed Product, plus
1215
+ // the sub-type (Geo) shared by two referrers (hq + warehouses[]).
1216
+ const up: Catalog.UpsertProduct.Response = null as any
1217
+ const lat: number = up.product.manufacturer.hq.geo.lat
1218
+ const wlng: number = up.product.manufacturer.warehouses[0]!.geo.lng
1219
+ const sku: string = up.product.variants[0]!.sku
1220
+ const w: number = up.product.dimensions.w
1221
+ const rev: number = up.audit.revision
1222
+ const editorName: string = up.audit.editor.name
1223
+ void lat; void wlng; void sku; void w; void rev; void editorName
1224
+
1225
+ // Route 1 params (body): shared Product + the colliding 'params' property.
1226
+ const body: Catalog.UpsertProduct.Params = null as any
1227
+ const pid: string = body.product.id
1228
+ const dry: boolean = body.params.dryRun
1229
+ void pid; void dry
1230
+
1231
+ // Route 2 request: Filter/Range shared across query + body channels.
1232
+ const req: Catalog.SearchCatalog.Req = null as any
1233
+ const page: number = req.query.page
1234
+ const qMin: number | undefined = req.query.filter?.price.min
1235
+ const bMax: number = req.body.saved[0]!.filter.price.max
1236
+ void page; void qMin; void bMax
1237
+
1238
+ // Route 2 response: array of the shared Product + a record.
1239
+ const res: Catalog.SearchCatalog.Response.Body = null as any
1240
+ const total: number = res.total
1241
+ const firstSku: string = res.items[0]!.variants[0]!.sku
1242
+ const bookCount: number | undefined = res.byCategory['book']
1243
+ void total; void firstSku; void bookCount
1244
+ `
1245
+
1246
+ const tsconfig = {
1247
+ compilerOptions: {
1248
+ strict: true,
1249
+ target: 'ES2022',
1250
+ module: 'ES2022',
1251
+ moduleResolution: 'bundler',
1252
+ verbatimModuleSyntax: true,
1253
+ noEmit: true,
1254
+ skipLibCheck: true,
1255
+ },
1256
+ include: ['_types.ts', '_client.ts', 'index.ts', 'catalog.ts', '_errors.ts', '__consumer.ts'],
1257
+ }
1258
+
1259
+ it('compiles end-to-end with deep shared/renamed types (default inline-union mode)', async () => {
1260
+ tmpDir = makeTmpDir()
1261
+ await generateClient({
1262
+ envelope: complexEnvelope,
1263
+ outDir: tmpDir,
1264
+ selfContained: true,
1265
+ namespaceTypes: true,
1266
+ })
1267
+ writeFileSync(join(tmpDir, '__consumer.ts'), consumer)
1268
+ runTsc({ tmpDir, tsconfigInline: tsconfig })
1269
+ })
1270
+
1271
+ it('compiles end-to-end under enumStyle: enum (union-of-literals explodes into many enums)', async () => {
1272
+ tmpDir = makeTmpDir()
1273
+ await generateClient({
1274
+ envelope: complexEnvelope,
1275
+ outDir: tmpDir,
1276
+ selfContained: true,
1277
+ namespaceTypes: true,
1278
+ ajsc: { enumStyle: 'enum' },
1279
+ })
1280
+ // In enum mode `category` becomes enum unions; the deep object access still
1281
+ // holds, so reuse the consumer (it never pins the enum member types).
1282
+ writeFileSync(join(tmpDir, '__consumer.ts'), consumer)
1283
+ runTsc({ tmpDir, tsconfigInline: tsconfig })
1284
+ })
1285
+ })
932
1286
  })
@@ -1399,4 +1399,190 @@ describe('emitScopeFile http-stream kind', () => {
1399
1399
  expect(out).toContain('TypedStream<Feed.StreamFeed.Yield, Feed.StreamFeed.ReturnType>')
1400
1400
  })
1401
1401
  })
1402
+
1403
+ // -------------------------------------------------------------------------
1404
+ // Duplicate-identifier bug: a type shared between params and returnType.
1405
+ //
1406
+ // ajsc (inlineTypes:false) glues SIBLING extracted declarations with a
1407
+ // single "\n" inside one block, separating only the Root with "\n\n". When
1408
+ // a route's request and response schemas each yield >=2 extracted sub-types,
1409
+ // the rename/dedup logic in emit-types only ever sees the FIRST declaration
1410
+ // in each fused block, so the non-first sub-types are emitted twice in the
1411
+ // same namespace -> `error TS2300: Duplicate identifier`.
1412
+ //
1413
+ // These assertions are fix-agnostic: they check the emitted contract (no
1414
+ // duplicate top-level identifiers in a route namespace; references resolve),
1415
+ // not any particular internal representation of the fix.
1416
+ // -------------------------------------------------------------------------
1417
+ describe('shared nested types across params/response (duplicate identifier)', () => {
1418
+ /** Every `export type|enum|interface <Name>` identifier declared in the output. */
1419
+ function declaredNames(output: string): string[] {
1420
+ const names: string[] = []
1421
+ const re = /export\s+(?:type|enum|interface)\s+(\w+)/g
1422
+ let m: RegExpExecArray | null
1423
+ while ((m = re.exec(output)) !== null) names.push(m[1]!)
1424
+ return names
1425
+ }
1426
+
1427
+ /** Identifiers that appear more than once. */
1428
+ function duplicates(names: string[]): string[] {
1429
+ const seen = new Set<string>()
1430
+ const dup = new Set<string>()
1431
+ for (const n of names) {
1432
+ if (seen.has(n)) dup.add(n)
1433
+ else seen.add(n)
1434
+ }
1435
+ return [...dup]
1436
+ }
1437
+
1438
+ /** All identifiers referenced inside `export type <Name> = <body>` bodies. */
1439
+ function referencedNames(output: string): Set<string> {
1440
+ const refs = new Set<string>()
1441
+ const re = /export\s+(?:type|interface)\s+\w+\s*=?\s*([^\n]*)/g
1442
+ let m: RegExpExecArray | null
1443
+ while ((m = re.exec(output)) !== null) {
1444
+ for (const id of m[1]!.matchAll(/\b([A-Z]\w*)\b/g)) refs.add(id[1]!)
1445
+ }
1446
+ return refs
1447
+ }
1448
+
1449
+ const contactShape = {
1450
+ type: 'object',
1451
+ required: ['name', 'address'],
1452
+ properties: {
1453
+ name: { type: 'string' },
1454
+ address: {
1455
+ type: 'object',
1456
+ required: ['street', 'city'],
1457
+ properties: { street: { type: 'string' }, city: { type: 'string' } },
1458
+ },
1459
+ },
1460
+ }
1461
+
1462
+ // body and response both contain `contact` (-> Contact + Address sub-types).
1463
+ const sharedNestedGroup: ScopeGroup = {
1464
+ scopeKey: 'contacts',
1465
+ camelCase: 'contacts',
1466
+ routes: [
1467
+ {
1468
+ kind: 'rpc',
1469
+ name: 'SaveContact',
1470
+ path: '/contacts/save/1',
1471
+ method: 'post',
1472
+ scope: 'contacts',
1473
+ version: 1,
1474
+ jsonSchema: {
1475
+ body: {
1476
+ type: 'object',
1477
+ required: ['contact'],
1478
+ properties: { contact: contactShape },
1479
+ },
1480
+ response: {
1481
+ type: 'object',
1482
+ required: ['contact', 'savedAt'],
1483
+ properties: { savedAt: { type: 'string' }, contact: contactShape },
1484
+ },
1485
+ },
1486
+ } satisfies RPCHttpRouteDoc,
1487
+ ],
1488
+ }
1489
+
1490
+ it('does not emit duplicate identifiers when a nested object is shared between body and response', async () => {
1491
+ const output = await emitScopeFile(sharedNestedGroup, { namespaceTypes: true })
1492
+ const dups = duplicates(declaredNames(output))
1493
+ expect(dups).toEqual([])
1494
+ })
1495
+
1496
+ it('every type referenced in a body resolves to a declared (or built-in) name', async () => {
1497
+ const output = await emitScopeFile(sharedNestedGroup, { namespaceTypes: true })
1498
+ const declared = new Set(declaredNames(output))
1499
+ // Built-in / framework type names that need no local declaration.
1500
+ const builtins = new Set(['Array', 'Record', 'Partial', 'Promise', 'TypedStream'])
1501
+ const unresolved = [...referencedNames(output)].filter(
1502
+ (r) => !declared.has(r) && !builtins.has(r),
1503
+ )
1504
+ expect(unresolved).toEqual([])
1505
+ })
1506
+
1507
+ // Two sibling object properties shared between body and response — also
1508
+ // produces a fused 2-declaration block, duplicating the second sub-type.
1509
+ const sharedSiblingsGroup: ScopeGroup = {
1510
+ scopeKey: 'records',
1511
+ camelCase: 'records',
1512
+ routes: [
1513
+ {
1514
+ kind: 'rpc',
1515
+ name: 'PutRecord',
1516
+ path: '/records/1',
1517
+ method: 'post',
1518
+ scope: 'records',
1519
+ version: 1,
1520
+ jsonSchema: {
1521
+ body: {
1522
+ type: 'object',
1523
+ required: ['alpha', 'beta'],
1524
+ properties: {
1525
+ alpha: { type: 'object', required: ['x'], properties: { x: { type: 'string' } } },
1526
+ beta: { type: 'object', required: ['y'], properties: { y: { type: 'string' } } },
1527
+ },
1528
+ },
1529
+ response: {
1530
+ type: 'object',
1531
+ required: ['alpha', 'beta'],
1532
+ properties: {
1533
+ alpha: { type: 'object', required: ['x'], properties: { x: { type: 'string' } } },
1534
+ beta: { type: 'object', required: ['y'], properties: { y: { type: 'string' } } },
1535
+ },
1536
+ },
1537
+ },
1538
+ } satisfies RPCHttpRouteDoc,
1539
+ ],
1540
+ }
1541
+
1542
+ it('does not emit duplicate identifiers when two sibling objects are shared', async () => {
1543
+ const output = await emitScopeFile(sharedSiblingsGroup, { namespaceTypes: true })
1544
+ const dups = duplicates(declaredNames(output))
1545
+ expect(dups).toEqual([])
1546
+ })
1547
+
1548
+ // Regression guard for the working case: a single shared nested sub-type
1549
+ // (1 extracted declaration per block) already renames correctly to *Inner.
1550
+ // This MUST stay green — the fix must not regress the single-sub-type path.
1551
+ const singleNestedGroup: ScopeGroup = {
1552
+ scopeKey: 'cities',
1553
+ camelCase: 'cities',
1554
+ routes: [
1555
+ {
1556
+ kind: 'rpc',
1557
+ name: 'SaveCity',
1558
+ path: '/cities/1',
1559
+ method: 'post',
1560
+ scope: 'cities',
1561
+ version: 1,
1562
+ jsonSchema: {
1563
+ body: {
1564
+ type: 'object',
1565
+ required: ['city'],
1566
+ properties: {
1567
+ city: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
1568
+ },
1569
+ },
1570
+ response: {
1571
+ type: 'object',
1572
+ required: ['city'],
1573
+ properties: {
1574
+ city: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
1575
+ },
1576
+ },
1577
+ },
1578
+ } satisfies RPCHttpRouteDoc,
1579
+ ],
1580
+ }
1581
+
1582
+ it('single shared nested sub-type stays free of duplicates (regression guard)', async () => {
1583
+ const output = await emitScopeFile(singleNestedGroup, { namespaceTypes: true })
1584
+ const dups = duplicates(declaredNames(output))
1585
+ expect(dups).toEqual([])
1586
+ })
1587
+ })
1402
1588
  })
@@ -10,6 +10,7 @@ import {
10
10
  jsonSchemaToTypeBody,
11
11
  jsonSchemaToExtractedTypes,
12
12
  renameExtractedTypes,
13
+ extractedDeclName,
13
14
  type AjscOptions,
14
15
  type ExtractedTypeOutput,
15
16
  } from './emit-types.js'
@@ -109,6 +110,45 @@ function indent(text: string, prefix: string): string {
109
110
  return text.split('\n').map((line) => (line ? prefix + line : line)).join('\n')
110
111
  }
111
112
 
113
+ /**
114
+ * Tracks extracted declarations emitted into a single namespace, guarding
115
+ * against duplicate identifiers (defense-in-depth on top of the rename pass).
116
+ *
117
+ * - Exact-string duplicates (the same sub-type extracted from two schemas) are
118
+ * silently skipped.
119
+ * - A same-name-but-different-body declaration is a genuine collision the
120
+ * rename pass failed to resolve; emitting it would produce an opaque
121
+ * `TS2300: Duplicate identifier` in the consumer's build. We fail fast at
122
+ * codegen with a message that names the offending identifier instead.
123
+ *
124
+ * Returns the indented declaration line to push, or `null` when it should be
125
+ * skipped (exact duplicate).
126
+ */
127
+ class DeclarationCollector {
128
+ private readonly seenStrings = new Set<string>()
129
+ private readonly seenNames = new Map<string, string>()
130
+
131
+ constructor(private readonly context: string) {}
132
+
133
+ /** Returns the indented line to emit, or `null` for an exact duplicate. */
134
+ accept(decl: string, indentPrefix: string): string | null {
135
+ if (this.seenStrings.has(decl)) return null
136
+ const name = extractedDeclName(decl)
137
+ if (name != null) {
138
+ if (this.seenNames.has(name)) {
139
+ throw new Error(
140
+ `[ts-procedures-codegen] duplicate identifier '${name}' while emitting ${this.context}. ` +
141
+ `An extracted sub-type collided with another of the same name and could not be renamed. ` +
142
+ `This is a codegen bug — please report it with the offending schema.`,
143
+ )
144
+ }
145
+ this.seenNames.set(name, decl)
146
+ }
147
+ this.seenStrings.add(decl)
148
+ return indent(decl, indentPrefix)
149
+ }
150
+ }
151
+
112
152
  interface NamedType {
113
153
  /** Short name for namespace mode (e.g., 'Params', 'Response'). */
114
154
  shortName: string
@@ -144,7 +184,7 @@ async function formatTypes(
144
184
 
145
185
  if (ctx.namespaceTypes) {
146
186
  const nsLines: string[] = []
147
- const seenDeclarations = new Set<string>()
187
+ const collector = new DeclarationCollector(`namespace ${routePascal}`)
148
188
 
149
189
  // Pre-reserve every name the route will declare itself (each shortName +
150
190
  // any caller-supplied extras). Extracted sub-types whose names land in
@@ -165,12 +205,10 @@ async function formatTypes(
165
205
 
166
206
  const result = renameExtractedTypes(rawResult, taken)
167
207
 
168
- // Collect extracted sub-types (deduplicate across schemas by exact-string)
208
+ // Collect extracted sub-types (dedupe exact dups; throw on real collisions)
169
209
  for (const decl of result.declarations) {
170
- if (!seenDeclarations.has(decl)) {
171
- seenDeclarations.add(decl)
172
- nsLines.push(indent(decl, ' '))
173
- }
210
+ const line = collector.accept(decl, ' ')
211
+ if (line != null) nsLines.push(line)
174
212
  }
175
213
 
176
214
  nsLines.push(` export type ${shortName} = ${result.body}`)
@@ -310,7 +348,7 @@ async function formatSubNamespace(
310
348
  ): Promise<{ nsBlock: string | null; refs: Record<string, string> }> {
311
349
  const refs: Record<string, string> = {}
312
350
  const nsLines: string[] = []
313
- const seenDeclarations = new Set<string>()
351
+ const collector = new DeclarationCollector(`namespace ${routePascal}.${nsName}`)
314
352
 
315
353
  // Pre-reserve short names to prevent sub-type extraction collision
316
354
  for (const t of types) {
@@ -327,10 +365,8 @@ async function formatSubNamespace(
327
365
  const result = renameExtractedTypes(rawResult, taken)
328
366
 
329
367
  for (const decl of result.declarations) {
330
- if (!seenDeclarations.has(decl)) {
331
- seenDeclarations.add(decl)
332
- nsLines.push(indent(decl, ' '))
333
- }
368
+ const line = collector.accept(decl, ' ')
369
+ if (line != null) nsLines.push(line)
334
370
  }
335
371
 
336
372
  nsLines.push(` export type ${shortName} = ${result.body}`)
@@ -580,17 +616,15 @@ async function emitHttpStreamRoute(route: HttpStreamRouteDoc, ctx: EmitRouteCont
580
616
  { shortName: 'Yield', schema: yieldSchema },
581
617
  { shortName: 'ReturnType', schema: returnSchema },
582
618
  ]
583
- const seenDeclarations = new Set<string>()
619
+ const collector = new DeclarationCollector(`namespace ${pascal}`)
584
620
  for (const { shortName, schema } of directTypes) {
585
621
  if (schema == null) continue
586
622
  const rawResult = await jsonSchemaToExtractedTypes(schema, ctx.ajsc)
587
623
  if (rawResult == null) continue
588
624
  const result = renameExtractedTypes(rawResult, taken)
589
625
  for (const decl of result.declarations) {
590
- if (!seenDeclarations.has(decl)) {
591
- seenDeclarations.add(decl)
592
- nsLines.push(indent(decl, ' '))
593
- }
626
+ const line = collector.accept(decl, ' ')
627
+ if (line != null) nsLines.push(line)
594
628
  }
595
629
  nsLines.push(` export type ${shortName} = ${result.body}`)
596
630
  }