genoc 0.4.0 → 0.4.2

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.
@@ -1,7 +1,8 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
2
  import { formatToBrandTypeName } from '../utils/case.js';
3
3
  import { buildTypeJsDoc, sanitizeTypeName } from '../utils/generator-helpers.js';
4
- import { quoteKey } from '../utils/string.js';
4
+ import { escapeStringLiteral, quoteKey } from '../utils/string.js';
5
+ import { parseJsonPointer } from '../utils/url.js';
5
6
  /** Indentation contract for multi-line types — 2-space unit pinned from the generated ServerParams interface; binding for downstream generators. */
6
7
  const INDENT_UNIT = ' ';
7
8
  function indentBy(level) {
@@ -28,6 +29,70 @@ function isComplexType(tsType) {
28
29
  function needsParens(tsType) {
29
30
  return tsType.includes(' | ') || tsType.includes(' & ');
30
31
  }
32
+ /**
33
+ * Whether the rendered type has a top-level `|` (depth 0, outside any
34
+ * brackets/braces and outside string literals). Unlike the substring check
35
+ * in `needsParens`, nested unions inside `{...}`, `<...>` or `'...'` do not
36
+ * trigger it — this is the guard for appending `& {...}` onto a type whose
37
+ * tail could otherwise capture the append (`X | null & {...}` loses the
38
+ * literal to `null`).
39
+ */
40
+ function hasTopLevelUnion(tsType) {
41
+ let depth = 0;
42
+ let inString = false;
43
+ for (let i = 0; i < tsType.length; i++) {
44
+ const ch = tsType[i];
45
+ if (ch === '\\') {
46
+ i++;
47
+ continue;
48
+ }
49
+ if (ch === "'") {
50
+ inString = !inString;
51
+ continue;
52
+ }
53
+ if (inString)
54
+ continue;
55
+ if (ch === '(' || ch === '{' || ch === '[' || ch === '<')
56
+ depth++;
57
+ else if (ch === ')' || ch === '}' || ch === ']' || ch === '>')
58
+ depth--;
59
+ else if (depth === 0 && ch === '|' && tsType[i - 1] === ' ' && tsType[i + 1] === ' ') {
60
+ return true;
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+ /** Decode the last segment of a `$ref` string into its raw component key (`~1`/`~0` unescaped). */
66
+ function decodedLastRefSegment(refStr) {
67
+ const segments = refStr.startsWith('#') ? parseJsonPointer(refStr.slice(1)) : refStr.split('/');
68
+ return segments[segments.length - 1] ?? refStr;
69
+ }
70
+ /** Encode a component key for embedding in a `#/components/schemas/…` ref string. */
71
+ function encodePointerSegment(segment) {
72
+ return segment.replace(/~/g, '~0').replace(/\//g, '~1');
73
+ }
74
+ function spineForEntry(entry) {
75
+ return {
76
+ familyId: entry.familyId,
77
+ propertyName: entry.propertyName,
78
+ literalValue: entry.literalValue,
79
+ };
80
+ }
81
+ /**
82
+ * A variant's own declared discriminant on the discriminator property:
83
+ * `const` first, then a single-value `enum` (D3). Multi-value enums and
84
+ * absent props yield nothing.
85
+ */
86
+ function ownDeclaredDiscriminant(propSchema) {
87
+ if (!propSchema)
88
+ return undefined;
89
+ if (propSchema.const !== undefined)
90
+ return String(propSchema.const);
91
+ if (propSchema.enum !== undefined && propSchema.enum.length === 1) {
92
+ return String(propSchema.enum[0]);
93
+ }
94
+ return undefined;
95
+ }
31
96
  /**
32
97
  * SchemaMapper converts OpenAPI 3.1 Schema Objects to TypeScript type strings.
33
98
  *
@@ -38,24 +103,33 @@ export class SchemaMapper {
38
103
  resolver;
39
104
  typeNameGenerator;
40
105
  discriminatorTargets;
41
- reservedNames;
106
+ emittedNames;
107
+ discriminatorRegistry;
42
108
  brandedTypes = new Map();
43
109
  nullableWarned = false;
44
110
  warnSink;
45
- constructor(resolver, typeNameGenerator, discriminatorTargets, reservedNames, warnSink) {
111
+ effectiveVersion;
112
+ constructor(resolver, opts) {
46
113
  // NOTE: discriminatorTargets must be keyed by names produced by the SAME
47
114
  // (rename-aware) typeNameGenerator that resolves $refs. Passing targets
48
115
  // keyed by raw schema names means renamed subtypes silently lose their
49
116
  // `& { prop: 'literal' }` intersection in the generated output.
117
+ // analyze() no longer builds or passes a targets map — the discriminator
118
+ // registry (when provided) is the SINGLE discriminator knowledge source
119
+ // (D1–D10 seam). The optional legacy targets param is kept for
120
+ // registry-less constructions; it only drives the legacy ref fallback in
121
+ // `resolveDiscriminatorInfo` below.
50
122
  this.resolver = resolver;
51
- this.typeNameGenerator = typeNameGenerator ?? defaultTypeNameGenerator;
52
- this.discriminatorTargets = discriminatorTargets ?? new Map();
53
- this.reservedNames = reservedNames ?? new Set();
123
+ this.typeNameGenerator = opts?.typeNameGenerator ?? defaultTypeNameGenerator;
124
+ this.discriminatorTargets = opts?.discriminatorTargets ?? new Map();
125
+ this.emittedNames = opts?.emittedNames ?? new Set();
126
+ this.discriminatorRegistry = opts?.discriminatorRegistry;
54
127
  this.warnSink =
55
- warnSink ??
128
+ opts?.warnSink ??
56
129
  ((message) => {
57
130
  process.stderr.write(message);
58
131
  });
132
+ this.effectiveVersion = opts?.effectiveVersion;
59
133
  }
60
134
  getBrandedTypes() {
61
135
  return this.brandedTypes;
@@ -73,12 +147,28 @@ export class SchemaMapper {
73
147
  return { tsType: schema ? 'unknown' : 'never', imports: [] };
74
148
  }
75
149
  const visited = new Set();
76
- const result = this.mapInternal(schema, name, context, visited, 0);
77
- if (name && this.discriminatorTargets.has(name)) {
78
- const target = this.discriminatorTargets.get(name);
79
- result.tsType += ` & { ${quoteKey(target.propertyName)}: '${target.literalValue}' }`;
150
+ if (this.discriminatorRegistry && name !== undefined) {
151
+ const entry = this.discriminatorRegistry.byName.get(name);
152
+ const spine = entry ? spineForEntry(entry) : undefined;
153
+ const result = this.mapInternal(schema, name, context, visited, 0, spine);
154
+ if (entry) {
155
+ return this.appendDiscriminatorLiteral(result, entry.propertyName, entry.literalValue);
156
+ }
157
+ return result;
80
158
  }
81
- return result;
159
+ return this.mapInternal(schema, name, context, visited, 0, undefined);
160
+ }
161
+ /**
162
+ * Append the discriminator literal intersection exactly ONCE (site 2),
163
+ * parenthesizing a top-level union so `X | null & {...}` cannot bind the
164
+ * literal to the last union member only (D9).
165
+ */
166
+ appendDiscriminatorLiteral(result, propertyName, literalValue) {
167
+ const appendage = ` & { ${quoteKey(propertyName)}: '${literalValue}' }`;
168
+ const tsType = hasTopLevelUnion(result.tsType)
169
+ ? `(${result.tsType})${appendage}`
170
+ : `${result.tsType}${appendage}`;
171
+ return { tsType, imports: result.imports };
82
172
  }
83
173
  getBrandTypeName(format, openApiType) {
84
174
  if (!format || format.trim() === '')
@@ -86,14 +176,17 @@ export class SchemaMapper {
86
176
  if (format === 'binary' || format === 'byte')
87
177
  return null;
88
178
  const brandName = formatToBrandTypeName(format, openApiType);
89
- if (this.reservedNames.has(brandName))
179
+ if (this.emittedNames.has(brandName))
90
180
  return null;
91
181
  return brandName;
92
182
  }
93
- mapInternal(schema, name, context, visited, indent) {
183
+ mapInternal(schema, name, context, visited, indent, spine) {
94
184
  if (isRefObject(schema)) {
95
185
  const refStr = schema.$ref;
96
186
  const refName = this.typeNameGenerator(refStr);
187
+ if (this.discriminatorRegistry) {
188
+ return this.translateRefThroughSeam(refStr, refName, context, visited, indent, spine);
189
+ }
97
190
  let resolved;
98
191
  try {
99
192
  resolved = this.resolver.resolveSchema(schema);
@@ -104,9 +197,8 @@ export class SchemaMapper {
104
197
  if (resolved) {
105
198
  const discInfo = this.resolveDiscriminatorInfo(resolved, refStr);
106
199
  if (discInfo) {
107
- const expanded = this.mapInternal(resolved, undefined, context, visited, indent);
108
- expanded.tsType += ` & { ${quoteKey(discInfo.propertyName)}: '${discInfo.literalValue}' }`;
109
- return expanded;
200
+ const expanded = this.mapInternal(resolved, undefined, context, visited, indent, undefined);
201
+ return this.appendDiscriminatorLiteral(expanded, discInfo.propertyName, escapeStringLiteral(discInfo.literalValue));
110
202
  }
111
203
  }
112
204
  return { tsType: refName, imports: [refName] };
@@ -122,7 +214,7 @@ export class SchemaMapper {
122
214
  // TODO: Remove deprecated nullable warning and handling when OpenAPI 3.1 support is complete
123
215
  // The 'nullable' property is deprecated in OpenAPI 3.1 in favor of type arrays like ["string", "null"]
124
216
  // This warning should be removed once full type array support is implemented
125
- if (s.nullable === true && !this.nullableWarned) {
217
+ if (s.nullable === true && this.effectiveVersion !== '3.0' && !this.nullableWarned) {
126
218
  this.warnSink('Warning: \'nullable\' is deprecated in OpenAPI 3.1. Use \'type: ["string", "null"]\' instead.');
127
219
  this.nullableWarned = true;
128
220
  }
@@ -134,7 +226,7 @@ export class SchemaMapper {
134
226
  return { tsType, imports: [] };
135
227
  }
136
228
  if (s.allOf !== undefined && s.allOf.length > 0) {
137
- const result = this.mapCombinator(s.allOf, '&', context, visited, indent);
229
+ const result = this.mapCombinator(s.allOf, '&', context, visited, indent, spine);
138
230
  if (s.nullable === true) {
139
231
  return {
140
232
  tsType: needsParens(result.tsType)
@@ -147,8 +239,8 @@ export class SchemaMapper {
147
239
  }
148
240
  if (s.oneOf !== undefined && s.oneOf.length > 0) {
149
241
  const result = s.discriminator
150
- ? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent)
151
- : this.mapCombinator(s.oneOf, '|', context, visited, indent);
242
+ ? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent, spine)
243
+ : this.mapCombinator(s.oneOf, '|', context, visited, indent, spine);
152
244
  if (s.nullable === true) {
153
245
  return {
154
246
  tsType: needsParens(result.tsType)
@@ -161,8 +253,8 @@ export class SchemaMapper {
161
253
  }
162
254
  if (s.anyOf !== undefined && s.anyOf.length > 0) {
163
255
  const result = s.discriminator
164
- ? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent)
165
- : this.mapCombinator(s.anyOf, '|', context, visited, indent);
256
+ ? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent, spine)
257
+ : this.mapCombinator(s.anyOf, '|', context, visited, indent, spine);
166
258
  if (s.nullable === true) {
167
259
  return {
168
260
  tsType: needsParens(result.tsType)
@@ -182,7 +274,7 @@ export class SchemaMapper {
182
274
  if (nonNull.length === 0) {
183
275
  return { tsType: 'null', imports: [] };
184
276
  }
185
- const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited), indent);
277
+ const baseResult = this.mapInternal({ ...s, type: nonNull[0] }, name, context, new Set(visited), indent, spine);
186
278
  if (hasNull) {
187
279
  return {
188
280
  tsType: `${baseResult.tsType} | null`,
@@ -239,11 +331,96 @@ export class SchemaMapper {
239
331
  case 'array':
240
332
  return this.mapArray(s, context, visited, indent);
241
333
  case 'object':
242
- return this.mapObject(s, name, context, visited, indent);
334
+ return this.mapObject(s, name, context, visited, indent, spine);
243
335
  default:
244
336
  return { tsType: 'unknown', imports: [] };
245
337
  }
246
338
  }
339
+ /**
340
+ * The single ref-translation seam (site 1). When the mapper carries a
341
+ * discriminator registry, EVERY `$ref` resolves through here:
342
+ *
343
+ * - a discriminator BASE becomes `{Base}Variant` at all sites except
344
+ * inside a same-family member's own definition spine, where it stays
345
+ * the bare base name (structural inheritance, no circularity — D1);
346
+ * - a same-family mapping target inside a spine becomes `Omit<M, 'P'>`,
347
+ * falling back to inline expansion WITHOUT the literal when M is a
348
+ * union, nullable, unnamed or non-object (D2/D4);
349
+ * - a mapping target at any other site is its bare name — the literal is
350
+ * injected exactly once, at the target's own named definition.
351
+ */
352
+ translateRefThroughSeam(refStr, refName, context, visited, indent, spine) {
353
+ const registry = this.discriminatorRegistry;
354
+ const canonical = this.canonicalSchemasRef(refStr);
355
+ const baseFamily = registry.baseRefs.get(refStr) ?? registry.baseRefs.get(canonical);
356
+ if (baseFamily) {
357
+ if (spine && spine.familyId === baseFamily.familyId) {
358
+ return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
359
+ }
360
+ return { tsType: baseFamily.variantUnionName, imports: [baseFamily.variantUnionName] };
361
+ }
362
+ const entry = registry.byRef.get(refStr) ?? registry.byRef.get(canonical);
363
+ if (entry) {
364
+ if (spine && spine.familyId === entry.familyId) {
365
+ return this.mapSameFamilySpineRef(entry, context, visited, indent);
366
+ }
367
+ if (entry.isNamed) {
368
+ return { tsType: entry.typeName, imports: [entry.typeName] };
369
+ }
370
+ // Unnamed target at a leaf site: expand inline and append the literal
371
+ // here — the target has no named definition to carry it.
372
+ const resolved = this.tryResolveRef(entry.refStr);
373
+ if (resolved) {
374
+ const expanded = this.mapInternal(resolved, undefined, context, visited, indent, spineForEntry(entry));
375
+ return this.appendDiscriminatorLiteral(expanded, entry.propertyName, entry.literalValue);
376
+ }
377
+ return { tsType: entry.typeName, imports: [entry.typeName] };
378
+ }
379
+ return { tsType: refName, imports: [refName] };
380
+ }
381
+ /**
382
+ * D2/D4 spine rule for a same-family `$ref`. `Omit<M, 'P'>` strips the
383
+ * sibling's own literal so the appender can re-add ours; the inline-strip
384
+ * fallback covers the cases where `Omit` would silently drop data
385
+ * (union target: `Omit<A|B,K>` drops variant props; nullable target:
386
+ * `Omit<M|null,K>` collapses to `{}`; unnamed/non-object target).
387
+ */
388
+ mapSameFamilySpineRef(entry, context, visited, indent) {
389
+ const resolved = this.tryResolveRef(entry.refStr);
390
+ if (entry.isNamed && !entry.isUnion && !entry.isNullable && this.isObjectIshSchema(resolved)) {
391
+ const key = `'${escapeStringLiteral(entry.propertyName)}'`;
392
+ return { tsType: `Omit<${entry.typeName}, ${key}>`, imports: [entry.typeName] };
393
+ }
394
+ if (resolved) {
395
+ return this.mapInternal(resolved, undefined, context, visited, indent, spineForEntry(entry));
396
+ }
397
+ return { tsType: entry.typeName, imports: [entry.typeName] };
398
+ }
399
+ /** Normalize a `#/components/schemas/…` ref to its canonically-encoded form (`a/b` ↔ `a~1b`). */
400
+ canonicalSchemasRef(refStr) {
401
+ if (!refStr.startsWith('#/components/schemas/'))
402
+ return refStr;
403
+ return `#/components/schemas/${encodePointerSegment(decodedLastRefSegment(refStr))}`;
404
+ }
405
+ tryResolveRef(refStr) {
406
+ try {
407
+ return this.resolver.resolveSchema({ $ref: refStr });
408
+ }
409
+ catch {
410
+ return undefined;
411
+ }
412
+ }
413
+ isObjectIshSchema(s) {
414
+ if (!s)
415
+ return false;
416
+ if (s.type === 'object')
417
+ return true;
418
+ if (Array.isArray(s.type))
419
+ return s.type.includes('object');
420
+ if (s.allOf !== undefined && s.allOf.length > 0)
421
+ return true;
422
+ return s.type === undefined && s.properties !== undefined;
423
+ }
247
424
  resolveDiscriminatorInfo(schema, refStr) {
248
425
  const schemaName = this.typeNameGenerator(refStr);
249
426
  const target = this.discriminatorTargets.get(schemaName);
@@ -272,7 +449,7 @@ export class SchemaMapper {
272
449
  return values
273
450
  .map((v) => {
274
451
  if (typeof v === 'string')
275
- return `'${v}'`;
452
+ return `'${escapeStringLiteral(v)}'`;
276
453
  if (typeof v === 'number')
277
454
  return String(v);
278
455
  if (typeof v === 'boolean')
@@ -283,8 +460,8 @@ export class SchemaMapper {
283
460
  })
284
461
  .join(' | ');
285
462
  }
286
- mapCombinator(schemas, kind, context, visited, indent) {
287
- const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited, indent));
463
+ mapCombinator(schemas, kind, context, visited, indent, spine) {
464
+ const results = schemas.map((s) => this.mapInternal(s, undefined, context, visited, indent, spine));
288
465
  const allImports = [];
289
466
  for (const r of results) {
290
467
  allImports.push(...r.imports);
@@ -298,20 +475,83 @@ export class SchemaMapper {
298
475
  });
299
476
  return { tsType: parts.join(separator), imports: allImports };
300
477
  }
301
- mapDiscriminatedUnion(schemas, discriminator, context, visited, indent) {
478
+ mapDiscriminatedUnion(schemas, discriminator, context, visited, indent, spine) {
302
479
  const propertyName = discriminator.propertyName;
303
480
  const mapping = discriminator.mapping;
304
481
  const allImports = [];
305
482
  const parts = [];
306
483
  for (const schema of schemas) {
307
- const discriminantValue = this.resolveDiscriminantValue(schema, propertyName, mapping);
308
- const variantResult = this.mapInternal(schema, undefined, context, visited, indent);
484
+ if (this.discriminatorRegistry && isRefObject(schema)) {
485
+ const refStr = schema.$ref;
486
+ const canonical = this.canonicalSchemasRef(refStr);
487
+ const entry = this.discriminatorRegistry.byRef.get(refStr) ??
488
+ this.discriminatorRegistry.byRef.get(canonical);
489
+ // D7: registered family variants are bare names — their named
490
+ // definitions already carry the literal exactly once. An explicit
491
+ // mapping defines the family's membership: when one exists, a
492
+ // oneOf member absent from it is foreign (wrapper + warn below);
493
+ // without a mapping every oneOf ref is an implicit member (D3).
494
+ const mappedRef = mapping !== undefined &&
495
+ (Object.values(mapping).includes(refStr) || Object.values(mapping).includes(canonical));
496
+ if (entry && entry.propertyName === propertyName && (mapping === undefined || mappedRef)) {
497
+ if (entry.isNamed) {
498
+ allImports.push(entry.typeName);
499
+ parts.push(entry.typeName);
500
+ continue;
501
+ }
502
+ const resolved = this.tryResolveRef(entry.refStr);
503
+ if (resolved) {
504
+ const expanded = this.mapInternal(resolved, undefined, context, visited, indent, spineForEntry(entry));
505
+ allImports.push(...expanded.imports);
506
+ parts.push(this.appendDiscriminatorLiteral(expanded, propertyName, entry.literalValue).tsType);
507
+ continue;
508
+ }
509
+ }
510
+ // Foreign (non-family) ref: keep the literal wrapper and warn —
511
+ // nothing guarantees the target declares its own discriminant.
512
+ this.warnSink(`Warning: discriminated union member "${refStr}" is not registered in a discriminator family; keeping the inline '{ ${propertyName}: <literal> } &' wrapper.\n`);
513
+ }
514
+ const discriminantValue = this.discriminatorRegistry
515
+ ? this.resolveRegistryDiscriminantValue(schema, propertyName, mapping)
516
+ : this.resolveDiscriminantValue(schema, propertyName, mapping);
517
+ const variantResult = this.mapInternal(schema, undefined, context, visited, indent, this.discriminatorRegistry ? undefined : spine);
309
518
  allImports.push(...variantResult.imports);
310
519
  const quotedProp = quoteKey(propertyName);
311
520
  parts.push(`({ ${quotedProp}: '${discriminantValue}' } & ${variantResult.tsType})`);
312
521
  }
313
522
  return { tsType: parts.join(' | '), imports: allImports };
314
523
  }
524
+ /**
525
+ * D3 literal precedence for registry-mode wrapper literals (inline variant
526
+ * schemas and foreign refs): mapping key > own `const` on the discriminant
527
+ * property > single-value `enum` > RAW last ref segment (the runtime
528
+ * value, NOT the sanitized type name). Values are escaped for a
529
+ * single-quoted TS string.
530
+ */
531
+ resolveRegistryDiscriminantValue(schema, propertyName, mapping) {
532
+ if (mapping && isRefObject(schema)) {
533
+ const refStr = schema.$ref;
534
+ for (const [value, ref] of Object.entries(mapping)) {
535
+ if (ref === refStr)
536
+ return escapeStringLiteral(value);
537
+ }
538
+ }
539
+ const resolved = isRefObject(schema)
540
+ ? this.tryResolveRef(schema.$ref)
541
+ : schema;
542
+ const propSchema = resolved?.properties?.[propertyName];
543
+ if (propSchema && propSchema.const !== undefined) {
544
+ return escapeStringLiteral(String(propSchema.const));
545
+ }
546
+ if (propSchema && propSchema.enum !== undefined && propSchema.enum.length === 1) {
547
+ return escapeStringLiteral(String(propSchema.enum[0]));
548
+ }
549
+ if (isRefObject(schema)) {
550
+ const refStr = schema.$ref;
551
+ return escapeStringLiteral(decodedLastRefSegment(refStr));
552
+ }
553
+ return 'unknown';
554
+ }
315
555
  resolveDiscriminantValue(schema, propertyName, mapping) {
316
556
  // Try explicit mapping first
317
557
  if (mapping && isRefObject(schema)) {
@@ -343,7 +583,7 @@ export class SchemaMapper {
343
583
  imports: [],
344
584
  };
345
585
  }
346
- const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent);
586
+ const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent, undefined);
347
587
  const tsType = isComplexType(itemResult.tsType)
348
588
  ? `Array<${itemResult.tsType}>`
349
589
  : `${itemResult.tsType}[]`;
@@ -378,7 +618,7 @@ export class SchemaMapper {
378
618
  const ind = indentBy(indentLevel);
379
619
  return block.split('\n').map((line) => `${ind}${line}`);
380
620
  }
381
- mapObject(schema, name, context, visited, indent) {
621
+ mapObject(schema, name, context, visited, indent, spine) {
382
622
  const properties = schema.properties ?? {};
383
623
  const requiredSet = new Set(schema.required ?? []);
384
624
  const propNames = Object.keys(properties);
@@ -393,6 +633,19 @@ export class SchemaMapper {
393
633
  return false;
394
634
  return true;
395
635
  });
636
+ // Inside a mapping target's own definition the discriminator property is
637
+ // supplied by the single literal append — an own redeclaration (const or
638
+ // enum) would intersect a conflicting literal and collapse to `never`.
639
+ if (spine) {
640
+ const ownDiscIndex = filteredPropNames.indexOf(spine.propertyName);
641
+ if (ownDiscIndex !== -1) {
642
+ const ownValue = ownDeclaredDiscriminant(properties[spine.propertyName]);
643
+ if (ownValue !== undefined && escapeStringLiteral(ownValue) !== spine.literalValue) {
644
+ this.warnSink(`Warning: Discriminator mapping key "${spine.literalValue}" for property "${spine.propertyName}" conflicts with the variant's own declared value "${ownValue}"; the mapping key wins.\n`);
645
+ }
646
+ filteredPropNames.splice(ownDiscIndex, 1);
647
+ }
648
+ }
396
649
  const ownIndent = indentBy(indent);
397
650
  const memberIndent = indentBy(indent + 1);
398
651
  const memberLines = [];
@@ -407,7 +660,7 @@ export class SchemaMapper {
407
660
  }
408
661
  for (const propName of filteredPropNames) {
409
662
  const propSchema = properties[propName];
410
- const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1);
663
+ const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1, undefined);
411
664
  allImports.push(...propResult.imports);
412
665
  memberLines.push(...this.renderPropertyJsDoc(propSchema, indent + 1));
413
666
  const optional = requiredSet.has(propName) ? '' : '?';
@@ -423,7 +676,7 @@ export class SchemaMapper {
423
676
  indexSignature = '[key: string]: unknown';
424
677
  }
425
678
  else if (additionalProps !== undefined && typeof additionalProps === 'object') {
426
- const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1);
679
+ const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1, undefined);
427
680
  allImports.push(...addPropResult.imports);
428
681
  indexSignature = `[key: string]: ${addPropResult.tsType}`;
429
682
  }
@@ -471,7 +724,9 @@ export class SchemaMapper {
471
724
  }
472
725
  if (schema.nullable === true) {
473
726
  return {
474
- tsType: `${result.tsType} | null`,
727
+ tsType: hasTopLevelUnion(result.tsType)
728
+ ? `(${result.tsType}) | null`
729
+ : `${result.tsType} | null`,
475
730
  imports: result.imports,
476
731
  };
477
732
  }
@@ -95,7 +95,9 @@ export interface AnalyzedSpec {
95
95
  /**
96
96
  * Section-1 component schema declarations as finished entries, in the
97
97
  * pre-refactor emission order: topologically sorted schemas first, then
98
- * discriminator `{Base}Variant` unions in discovery order.
98
+ * one always-emitted `{Base}Variant` union per discriminator family
99
+ * (members = mapping values ∪ implicit oneOf/anyOf refs, sourced from the
100
+ * discriminator registry; collision-safe names) in family discovery order.
99
101
  */
100
102
  schemaTypes: TypeDeclaration[];
101
103
  /** Branded format types discovered during mapping, in discovery order. */
@@ -116,4 +118,9 @@ export interface AnalyzeOptions {
116
118
  resolver?: RefResolver;
117
119
  /** Method naming strategy; defaults to `'path-based'`. */
118
120
  strategy?: MethodNameStrategy;
121
+ /**
122
+ * Effective OpenAPI dialect, sourced from `VersionProfile.effective`.
123
+ * Omitted is treated as `'3.1'`.
124
+ */
125
+ effectiveVersion?: '3.0' | '3.1';
119
126
  }
package/dist/cli/impl.js CHANGED
@@ -165,7 +165,10 @@ async function runOneTarget(target, context) {
165
165
  };
166
166
  const { preserveRefSiblings } = profile;
167
167
  context.process.stdout.write('Generating client...\n');
168
- await generateFullOutput(doc, config, { preserveRefSiblings });
168
+ await generateFullOutput(doc, config, {
169
+ preserveRefSiblings,
170
+ effectiveVersion: profile.effective,
171
+ });
169
172
  context.process.stdout.write(`✅ Success! Generated client files:\n`);
170
173
  context.process.stdout.write(` - ${target.outputDir}/contracts.ts\n`);
171
174
  context.process.stdout.write(` - ${target.outputDir}/client.ts\n`);
@@ -1,34 +1,50 @@
1
1
  import { sanitizeJsDocText } from '../utils/generator-helpers.js';
2
2
  import { getSuccessType, operationEmissions } from '../utils/operation-naming.js';
3
+ /**
4
+ * A `?:` parameter may only be followed by other optional parameters — a
5
+ * required parameter after an optional one is a TS1016 compile error
6
+ * ("A required parameter cannot follow an optional parameter"). Optional
7
+ * parameters that precede a required one are therefore rendered as
8
+ * `name: T | undefined` instead of `name?: T`.
9
+ */
10
+ function renderParams(specs) {
11
+ return specs
12
+ .map((spec, index) => {
13
+ if (!spec.isOptional) {
14
+ return `${spec.name}: ${spec.type}`;
15
+ }
16
+ const hasRequiredAfter = specs.slice(index + 1).some((s) => !s.isOptional);
17
+ if (hasRequiredAfter) {
18
+ return `${spec.name}: ${spec.type} | undefined`;
19
+ }
20
+ return `${spec.name}?: ${spec.type}`;
21
+ })
22
+ .join(', ');
23
+ }
3
24
  function buildParameters(op) {
4
- const params = [];
5
25
  const emissions = operationEmissions(op);
26
+ const specs = [];
6
27
  for (const param of op.pathParams) {
7
- params.push(`${param.name}: string`);
28
+ specs.push({ name: param.name, type: 'string', isOptional: false });
8
29
  }
9
30
  if (emissions.query !== undefined) {
10
31
  const allOptional = op.queryParams.every((p) => !p.required);
11
- const hasRequiredAfter = !!op.requestBody?.required || op.headerParams.some((p) => p.required);
12
- if (allOptional && hasRequiredAfter) {
13
- // All optional query params + required param after: use explicit undefined to avoid "required param cannot follow optional" error
14
- params.push(`query: ${emissions.query} | undefined`);
15
- }
16
- else {
17
- // Normal case: use optional notation
18
- const optional = allOptional ? '?' : '';
19
- params.push(`query${optional}: ${emissions.query}`);
20
- }
32
+ specs.push({ name: 'query', type: emissions.query, isOptional: allOptional });
21
33
  }
22
34
  if (op.requestBody) {
23
- const optional = op.requestBody.required ? '' : '?';
24
- params.push(`body${optional}: ${emissions.body}`);
35
+ // A schema-less body has no named type; the literal keeps the rendered
36
+ // signature byte-identical to the previous template-literal fallback.
37
+ specs.push({
38
+ name: 'body',
39
+ type: emissions.body ?? 'undefined',
40
+ isOptional: !op.requestBody.required,
41
+ });
25
42
  }
26
43
  if (emissions.headers !== undefined) {
27
44
  const allOptional = op.headerParams.every((p) => !p.required);
28
- const optional = allOptional ? '?' : '';
29
- params.push(`headers${optional}: ${emissions.headers}`);
45
+ specs.push({ name: 'headers', type: emissions.headers, isOptional: allOptional });
30
46
  }
31
- return params.join(', ');
47
+ return renderParams(specs);
32
48
  }
33
49
  function buildJsDoc(op) {
34
50
  const lines = [];
@@ -4,6 +4,8 @@ import type { OpenAPIDocument } from './types/openapi.js';
4
4
  export interface GenerationOptions {
5
5
  /** When true, sibling properties alongside $ref are preserved (OpenAPI 3.1 behavior). */
6
6
  preserveRefSiblings?: boolean;
7
+ /** Effective OpenAPI dialect, sourced from `VersionProfile.effective`. */
8
+ effectiveVersion?: '3.0' | '3.1';
7
9
  }
8
10
  /**
9
11
  * Generate and write all output files to disk: builds the resolver, runs the
package/dist/pipeline.js CHANGED
@@ -15,6 +15,7 @@ export async function generateFullOutput(doc, config, options) {
15
15
  const analyzed = analyze(doc, {
16
16
  resolver,
17
17
  strategy: config.methodNameStrategy ?? 'path-based',
18
+ effectiveVersion: options?.effectiveVersion,
18
19
  });
19
20
  const { contracts, client, index } = generateOutput(analyzed, config);
20
21
  await mkdir(config.outputDir, { recursive: true });
@@ -3,4 +3,6 @@ export type LoadOptions = {
3
3
  };
4
4
  export declare function assertValidProxyUrl(url: string): void;
5
5
  export declare function hasProxyEnv(): boolean;
6
+ /** True when the dynamic `import('undici')` failed because undici is not installed (Node: ERR_MODULE_NOT_FOUND; Bun: "Cannot find package" message). */
7
+ export declare function isMissingUndiciError(err: unknown): boolean;
6
8
  export declare function fetchSpec(url: string, opts?: LoadOptions): Promise<Response>;