genoc 0.3.0 → 0.4.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 (71) hide show
  1. package/README.md +31 -187
  2. package/dist/analyzer/analyze.d.ts +18 -0
  3. package/dist/analyzer/analyze.js +493 -0
  4. package/dist/analyzer/discriminator-registry.d.ts +108 -0
  5. package/dist/analyzer/discriminator-registry.js +209 -0
  6. package/dist/analyzer/path-analyzer.d.ts +43 -6
  7. package/dist/analyzer/path-analyzer.js +57 -84
  8. package/dist/analyzer/schema-mapper.d.ts +68 -6
  9. package/dist/analyzer/schema-mapper.js +299 -40
  10. package/dist/analyzer/types.d.ts +126 -0
  11. package/dist/cli/app.d.ts +4 -2
  12. package/dist/cli/app.js +23 -4
  13. package/dist/cli/config-loader.d.ts +20 -0
  14. package/dist/cli/config-loader.js +186 -0
  15. package/dist/cli/config-schema.d.ts +105 -0
  16. package/dist/cli/config-schema.js +183 -0
  17. package/dist/cli/impl.d.ts +9 -1
  18. package/dist/cli/impl.js +189 -36
  19. package/dist/generator/client-generator.d.ts +13 -14
  20. package/dist/generator/client-generator.js +45 -77
  21. package/dist/generator/contracts-generator.d.ts +11 -9
  22. package/dist/generator/contracts-generator.js +45 -491
  23. package/dist/generator/method-generator.d.ts +1 -1
  24. package/dist/generator/method-generator.js +10 -150
  25. package/dist/parser/ref-resolver.d.ts +1 -1
  26. package/dist/parser/ref-resolver.js +1 -2
  27. package/dist/parser/version/index.d.ts +4 -16
  28. package/dist/parser/version/index.js +3 -14
  29. package/dist/parser/version/v3.0/validator.d.ts +1 -1
  30. package/dist/parser/version/v3.1/validator.d.ts +17 -0
  31. package/dist/parser/version/v3.1/validator.js +126 -0
  32. package/dist/parser/version/validate.d.ts +9 -0
  33. package/dist/parser/version/validate.js +12 -0
  34. package/dist/parser/version/version-detector.d.ts +21 -0
  35. package/dist/parser/version/version-detector.js +19 -0
  36. package/dist/pipeline.d.ts +15 -0
  37. package/dist/pipeline.js +25 -0
  38. package/dist/types/client.d.ts +0 -12
  39. package/dist/utils/generator-helpers.d.ts +1 -24
  40. package/dist/utils/generator-helpers.js +11 -78
  41. package/dist/utils/operation-naming.d.ts +84 -0
  42. package/dist/utils/operation-naming.js +178 -0
  43. package/dist/utils/proxy.d.ts +2 -0
  44. package/dist/utils/proxy.js +16 -0
  45. package/dist/utils/string.d.ts +7 -0
  46. package/dist/utils/string.js +9 -0
  47. package/package.json +19 -15
  48. package/dist/index.d.ts +0 -9
  49. package/dist/index.js +0 -11
  50. package/dist/parser/validators.d.ts +0 -7
  51. package/dist/parser/validators.js +0 -79
  52. package/dist/parser/version/normalized-spec.d.ts +0 -199
  53. package/dist/parser/version/registry.d.ts +0 -28
  54. package/dist/parser/version/registry.js +0 -44
  55. package/dist/parser/version/v3.0/index.d.ts +0 -3
  56. package/dist/parser/version/v3.0/index.js +0 -3
  57. package/dist/parser/version/v3.0/normalizer.d.ts +0 -15
  58. package/dist/parser/version/v3.0/normalizer.js +0 -389
  59. package/dist/parser/version/v3.0/strategy.d.ts +0 -27
  60. package/dist/parser/version/v3.0/strategy.js +0 -96
  61. package/dist/parser/version/v3.1/index.d.ts +0 -1
  62. package/dist/parser/version/v3.1/index.js +0 -1
  63. package/dist/parser/version/v3.1/strategy.d.ts +0 -42
  64. package/dist/parser/version/v3.1/strategy.js +0 -513
  65. package/dist/parser/version/v3.2/index.d.ts +0 -4
  66. package/dist/parser/version/v3.2/index.js +0 -4
  67. package/dist/parser/version/v3.2/strategy.d.ts +0 -39
  68. package/dist/parser/version/v3.2/strategy.js +0 -57
  69. package/dist/parser/version/version-strategy.d.ts +0 -31
  70. package/dist/parser/version/version-strategy.js +0 -1
  71. /package/dist/{parser/version/normalized-spec.js → analyzer/types.js} +0 -0
@@ -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,18 +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
- static nullableWarned = false;
44
- constructor(resolver, typeNameGenerator, discriminatorTargets, reservedNames) {
109
+ nullableWarned = false;
110
+ warnSink;
111
+ effectiveVersion;
112
+ constructor(resolver, opts) {
45
113
  // NOTE: discriminatorTargets must be keyed by names produced by the SAME
46
114
  // (rename-aware) typeNameGenerator that resolves $refs. Passing targets
47
115
  // keyed by raw schema names means renamed subtypes silently lose their
48
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.
49
122
  this.resolver = resolver;
50
- this.typeNameGenerator = typeNameGenerator ?? defaultTypeNameGenerator;
51
- this.discriminatorTargets = discriminatorTargets ?? new Map();
52
- 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;
127
+ this.warnSink =
128
+ opts?.warnSink ??
129
+ ((message) => {
130
+ process.stderr.write(message);
131
+ });
132
+ this.effectiveVersion = opts?.effectiveVersion;
53
133
  }
54
134
  getBrandedTypes() {
55
135
  return this.brandedTypes;
@@ -67,12 +147,28 @@ export class SchemaMapper {
67
147
  return { tsType: schema ? 'unknown' : 'never', imports: [] };
68
148
  }
69
149
  const visited = new Set();
70
- const result = this.mapInternal(schema, name, context, visited, 0);
71
- if (name && this.discriminatorTargets.has(name)) {
72
- const target = this.discriminatorTargets.get(name);
73
- 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;
74
158
  }
75
- 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 };
76
172
  }
77
173
  getBrandTypeName(format, openApiType) {
78
174
  if (!format || format.trim() === '')
@@ -80,14 +176,17 @@ export class SchemaMapper {
80
176
  if (format === 'binary' || format === 'byte')
81
177
  return null;
82
178
  const brandName = formatToBrandTypeName(format, openApiType);
83
- if (this.reservedNames.has(brandName))
179
+ if (this.emittedNames.has(brandName))
84
180
  return null;
85
181
  return brandName;
86
182
  }
87
- mapInternal(schema, name, context, visited, indent) {
183
+ mapInternal(schema, name, context, visited, indent, spine) {
88
184
  if (isRefObject(schema)) {
89
185
  const refStr = schema.$ref;
90
186
  const refName = this.typeNameGenerator(refStr);
187
+ if (this.discriminatorRegistry) {
188
+ return this.translateRefThroughSeam(refStr, refName, context, visited, indent, spine);
189
+ }
91
190
  let resolved;
92
191
  try {
93
192
  resolved = this.resolver.resolveSchema(schema);
@@ -98,9 +197,8 @@ export class SchemaMapper {
98
197
  if (resolved) {
99
198
  const discInfo = this.resolveDiscriminatorInfo(resolved, refStr);
100
199
  if (discInfo) {
101
- const expanded = this.mapInternal(resolved, undefined, context, visited, indent);
102
- expanded.tsType += ` & { ${quoteKey(discInfo.propertyName)}: '${discInfo.literalValue}' }`;
103
- return expanded;
200
+ const expanded = this.mapInternal(resolved, undefined, context, visited, indent, undefined);
201
+ return this.appendDiscriminatorLiteral(expanded, discInfo.propertyName, escapeStringLiteral(discInfo.literalValue));
104
202
  }
105
203
  }
106
204
  return { tsType: refName, imports: [refName] };
@@ -116,11 +214,9 @@ export class SchemaMapper {
116
214
  // TODO: Remove deprecated nullable warning and handling when OpenAPI 3.1 support is complete
117
215
  // The 'nullable' property is deprecated in OpenAPI 3.1 in favor of type arrays like ["string", "null"]
118
216
  // This warning should be removed once full type array support is implemented
119
- if (s.nullable === true && !SchemaMapper.nullableWarned) {
120
- // TODO: Replace with structured logging solution
121
- // oxlint-disable-next-line no-console
122
- console.warn('Warning: \'nullable\' is deprecated in OpenAPI 3.1. Use \'type: ["string", "null"]\' instead.');
123
- SchemaMapper.nullableWarned = true;
217
+ if (s.nullable === true && this.effectiveVersion !== '3.0' && !this.nullableWarned) {
218
+ this.warnSink('Warning: \'nullable\' is deprecated in OpenAPI 3.1. Use \'type: ["string", "null"]\' instead.');
219
+ this.nullableWarned = true;
124
220
  }
125
221
  if (s.enum !== undefined && s.enum.length > 0) {
126
222
  let tsType = this.mapEnumValues(s.enum);
@@ -130,7 +226,7 @@ export class SchemaMapper {
130
226
  return { tsType, imports: [] };
131
227
  }
132
228
  if (s.allOf !== undefined && s.allOf.length > 0) {
133
- const result = this.mapCombinator(s.allOf, '&', context, visited, indent);
229
+ const result = this.mapCombinator(s.allOf, '&', context, visited, indent, spine);
134
230
  if (s.nullable === true) {
135
231
  return {
136
232
  tsType: needsParens(result.tsType)
@@ -143,8 +239,8 @@ export class SchemaMapper {
143
239
  }
144
240
  if (s.oneOf !== undefined && s.oneOf.length > 0) {
145
241
  const result = s.discriminator
146
- ? this.mapDiscriminatedUnion(s.oneOf, s.discriminator, context, visited, indent)
147
- : 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);
148
244
  if (s.nullable === true) {
149
245
  return {
150
246
  tsType: needsParens(result.tsType)
@@ -157,8 +253,8 @@ export class SchemaMapper {
157
253
  }
158
254
  if (s.anyOf !== undefined && s.anyOf.length > 0) {
159
255
  const result = s.discriminator
160
- ? this.mapDiscriminatedUnion(s.anyOf, s.discriminator, context, visited, indent)
161
- : 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);
162
258
  if (s.nullable === true) {
163
259
  return {
164
260
  tsType: needsParens(result.tsType)
@@ -178,7 +274,7 @@ export class SchemaMapper {
178
274
  if (nonNull.length === 0) {
179
275
  return { tsType: 'null', imports: [] };
180
276
  }
181
- 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);
182
278
  if (hasNull) {
183
279
  return {
184
280
  tsType: `${baseResult.tsType} | null`,
@@ -235,11 +331,96 @@ export class SchemaMapper {
235
331
  case 'array':
236
332
  return this.mapArray(s, context, visited, indent);
237
333
  case 'object':
238
- return this.mapObject(s, name, context, visited, indent);
334
+ return this.mapObject(s, name, context, visited, indent, spine);
239
335
  default:
240
336
  return { tsType: 'unknown', imports: [] };
241
337
  }
242
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
+ }
243
424
  resolveDiscriminatorInfo(schema, refStr) {
244
425
  const schemaName = this.typeNameGenerator(refStr);
245
426
  const target = this.discriminatorTargets.get(schemaName);
@@ -268,7 +449,7 @@ export class SchemaMapper {
268
449
  return values
269
450
  .map((v) => {
270
451
  if (typeof v === 'string')
271
- return `'${v}'`;
452
+ return `'${escapeStringLiteral(v)}'`;
272
453
  if (typeof v === 'number')
273
454
  return String(v);
274
455
  if (typeof v === 'boolean')
@@ -279,8 +460,8 @@ export class SchemaMapper {
279
460
  })
280
461
  .join(' | ');
281
462
  }
282
- mapCombinator(schemas, kind, context, visited, indent) {
283
- 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));
284
465
  const allImports = [];
285
466
  for (const r of results) {
286
467
  allImports.push(...r.imports);
@@ -294,20 +475,83 @@ export class SchemaMapper {
294
475
  });
295
476
  return { tsType: parts.join(separator), imports: allImports };
296
477
  }
297
- mapDiscriminatedUnion(schemas, discriminator, context, visited, indent) {
478
+ mapDiscriminatedUnion(schemas, discriminator, context, visited, indent, spine) {
298
479
  const propertyName = discriminator.propertyName;
299
480
  const mapping = discriminator.mapping;
300
481
  const allImports = [];
301
482
  const parts = [];
302
483
  for (const schema of schemas) {
303
- const discriminantValue = this.resolveDiscriminantValue(schema, propertyName, mapping);
304
- 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);
305
518
  allImports.push(...variantResult.imports);
306
519
  const quotedProp = quoteKey(propertyName);
307
520
  parts.push(`({ ${quotedProp}: '${discriminantValue}' } & ${variantResult.tsType})`);
308
521
  }
309
522
  return { tsType: parts.join(' | '), imports: allImports };
310
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
+ }
311
555
  resolveDiscriminantValue(schema, propertyName, mapping) {
312
556
  // Try explicit mapping first
313
557
  if (mapping && isRefObject(schema)) {
@@ -339,7 +583,7 @@ export class SchemaMapper {
339
583
  imports: [],
340
584
  };
341
585
  }
342
- const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent);
586
+ const itemResult = this.mapInternal(schema.items, undefined, context, visited, indent, undefined);
343
587
  const tsType = isComplexType(itemResult.tsType)
344
588
  ? `Array<${itemResult.tsType}>`
345
589
  : `${itemResult.tsType}[]`;
@@ -374,7 +618,7 @@ export class SchemaMapper {
374
618
  const ind = indentBy(indentLevel);
375
619
  return block.split('\n').map((line) => `${ind}${line}`);
376
620
  }
377
- mapObject(schema, name, context, visited, indent) {
621
+ mapObject(schema, name, context, visited, indent, spine) {
378
622
  const properties = schema.properties ?? {};
379
623
  const requiredSet = new Set(schema.required ?? []);
380
624
  const propNames = Object.keys(properties);
@@ -389,6 +633,19 @@ export class SchemaMapper {
389
633
  return false;
390
634
  return true;
391
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
+ }
392
649
  const ownIndent = indentBy(indent);
393
650
  const memberIndent = indentBy(indent + 1);
394
651
  const memberLines = [];
@@ -403,7 +660,7 @@ export class SchemaMapper {
403
660
  }
404
661
  for (const propName of filteredPropNames) {
405
662
  const propSchema = properties[propName];
406
- const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1);
663
+ const propResult = this.mapInternal(propSchema, undefined, context, visited, indent + 1, undefined);
407
664
  allImports.push(...propResult.imports);
408
665
  memberLines.push(...this.renderPropertyJsDoc(propSchema, indent + 1));
409
666
  const optional = requiredSet.has(propName) ? '' : '?';
@@ -419,7 +676,7 @@ export class SchemaMapper {
419
676
  indexSignature = '[key: string]: unknown';
420
677
  }
421
678
  else if (additionalProps !== undefined && typeof additionalProps === 'object') {
422
- const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1);
679
+ const addPropResult = this.mapInternal(additionalProps, undefined, context, visited, indent + 1, undefined);
423
680
  allImports.push(...addPropResult.imports);
424
681
  indexSignature = `[key: string]: ${addPropResult.tsType}`;
425
682
  }
@@ -467,7 +724,9 @@ export class SchemaMapper {
467
724
  }
468
725
  if (schema.nullable === true) {
469
726
  return {
470
- tsType: `${result.tsType} | null`,
727
+ tsType: hasTopLevelUnion(result.tsType)
728
+ ? `(${result.tsType}) | null`
729
+ : `${result.tsType} | null`,
471
730
  imports: result.imports,
472
731
  };
473
732
  }
@@ -0,0 +1,126 @@
1
+ import type { RefResolver } from '../parser/ref-resolver.js';
2
+ import type { MethodNameStrategy } from '../types/client.js';
3
+ import type { AnalyzedOperation } from './path-analyzer.js';
4
+ /**
5
+ * Finished (render-ready) type declaration for the contracts file: the emitted
6
+ * type name, its complete definition statement, and an optional type-level
7
+ * JSDoc block rendered above it.
8
+ */
9
+ export interface TypeDeclaration {
10
+ /** Emitted type name (rename-aware). */
11
+ name: string;
12
+ /** Complete definition statement, e.g. `export type User = { … };`. */
13
+ definition: string;
14
+ /** Type-level JSDoc block, when the source carries renderable metadata. */
15
+ jsDoc?: string;
16
+ }
17
+ /**
18
+ * Finished security scheme type declaration (contracts Section 1b): the
19
+ * collision-deduped `{Name}Auth` type name, the rendered object type text,
20
+ * and the description-only JSDoc block ('' when absent).
21
+ */
22
+ export interface SecuritySchemeTypeDeclaration {
23
+ /** Emitted `{Name}Auth` type name (sanitized, numbered on collision). */
24
+ name: string;
25
+ /** Rendered object type text — the `securitySchemeToTsType` output. */
26
+ tsType: string;
27
+ /** Description JSDoc block; '' when the scheme carries no description. */
28
+ jsDoc: string;
29
+ }
30
+ /**
31
+ * Finished server variable type declaration (contracts Section 1c). Names
32
+ * are index-aware over ALL `doc.servers` — `ServerParams` when the document
33
+ * declares a single server, `Server{i}Params` otherwise — so servers without
34
+ * variables still occupy an index.
35
+ */
36
+ export interface ServerTypeDeclaration {
37
+ name: string;
38
+ /** Server URL, emitted as the `/** Server: url *\/` comment when present. */
39
+ url?: string;
40
+ /** Finished interface body lines — variable JSDoc comments and property lines, indentation included. */
41
+ propertyLines: string[];
42
+ }
43
+ /** Branded format type discovered by the schema mapper during translation. */
44
+ export interface BrandedTypeDeclaration {
45
+ name: string;
46
+ format: string;
47
+ baseType: string;
48
+ }
49
+ /**
50
+ * One multipart `FileInput` property fact from the ref-resolving walk of a
51
+ * multipart request body ($refs followed; a resolved `format: binary`
52
+ * property is a `file`/`file-array`, not a `field`).
53
+ */
54
+ export interface FileUploadPropertyFact {
55
+ /** Property name in the multipart schema. */
56
+ name: string;
57
+ /** Whether the property is listed in the schema's `required` array. */
58
+ required: boolean;
59
+ /** `file` → `FileInput`, `file-array` → `FileInput[]`, `field` → `string`. */
60
+ kind: 'file' | 'file-array' | 'field';
61
+ }
62
+ /**
63
+ * Analyzed operation plus its finished translation results.
64
+ *
65
+ * The structural fields are the plain `AnalyzedOperation`; the added fields
66
+ * carry the real-mapper output the contracts renderer emits verbatim.
67
+ */
68
+ export interface FinishedOperation extends AnalyzedOperation {
69
+ /**
70
+ * Finished contracts-file emission lines for this operation (Sections 2–4:
71
+ * query/header/body/response/error types, with JSDoc attached), in emission
72
+ * order. Assembled by `analyze()`; `renderContracts` spills them unchanged.
73
+ */
74
+ contractsLines: string[];
75
+ /**
76
+ * Multipart `FileInput` property facts from the ref-resolving walk of the
77
+ * request body schema (`$ref`s followed; a resolved `format: binary`
78
+ * property is a `file`/`file-array`, not a `field`). Present exactly when
79
+ * the operation has a multipart body WITH a schema — an empty array means a
80
+ * zero-property body; `undefined` means no multipart schema (no FormData
81
+ * emission). Sole fact source for the client generator's FormData appends.
82
+ */
83
+ fileUploadProperties?: FileUploadPropertyFact[];
84
+ }
85
+ /**
86
+ * Single-pass analysis model of an OpenAPI document — the ONLY input every
87
+ * generator renderer consumes. Renderers get the model plus config; they never
88
+ * touch the raw spec or a `RefResolver`.
89
+ */
90
+ export interface AnalyzedSpec {
91
+ /** `doc.openapi` verbatim (e.g. `"3.1.0"`) — feeds generated file headers. */
92
+ specVersion: string;
93
+ /** Operations with structural facts and finished contracts emission lines. */
94
+ operations: FinishedOperation[];
95
+ /**
96
+ * Section-1 component schema declarations as finished entries, in the
97
+ * pre-refactor emission order: topologically sorted schemas first, then
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.
101
+ */
102
+ schemaTypes: TypeDeclaration[];
103
+ /** Branded format types discovered during mapping, in discovery order. */
104
+ brandedTypes: BrandedTypeDeclaration[];
105
+ /** Any operation has a multipart request body → contracts emits `FileInput`. */
106
+ hasFileUpload: boolean;
107
+ /**
108
+ * Finished security scheme type declarations (`{Name}Auth`), in spec key
109
+ * order. The `SecuritySchemes` union is emitted when more than one exists.
110
+ */
111
+ securitySchemeTypes: SecuritySchemeTypeDeclaration[];
112
+ /** Finished server variable interface declarations, in spec order. */
113
+ serverTypes: ServerTypeDeclaration[];
114
+ }
115
+ /** Options for `analyze()`. */
116
+ export interface AnalyzeOptions {
117
+ /** Resolver for `$ref` pointers; defaults to a fresh `RefResolver(doc)`. */
118
+ resolver?: RefResolver;
119
+ /** Method naming strategy; defaults to `'path-based'`. */
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';
126
+ }
package/dist/cli/app.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  interface Flags {
2
- outputDir: string;
2
+ outputDir?: string;
3
3
  methodNameStrategy: 'path-based' | 'operationId' | 'operationId-with-fallback';
4
4
  specVersion?: string;
5
- strictVersion: boolean;
5
+ strictVersion?: boolean;
6
6
  runtimeImportPath?: string;
7
7
  proxy?: string;
8
+ config?: string;
9
+ project?: string;
8
10
  }
9
11
  export type AppFlags = Flags;
10
12
  export declare const app: import("@stricli/core").Application<import("@stricli/core").CommandContext>;