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
@@ -0,0 +1,209 @@
1
+ import { buildSchemaRenameMap, sanitizeTypeName } from '../utils/generator-helpers.js';
2
+ import { RESERVED_TYPE_NAMES } from '../utils/operation-naming.js';
3
+ import { escapeStringLiteral } from '../utils/string.js';
4
+ import { parseJsonPointer } from '../utils/url.js';
5
+ /**
6
+ * Family-aware discriminator registry (plan D5) — the single knowledge source
7
+ * for discriminator families the mapper seam will translate through:
8
+ *
9
+ * - One record per discriminator family (a component schema declaring
10
+ * `discriminator`), keyed by `familyId` (the base's raw component name).
11
+ * - Variant entries keyed by the FULL `$ref` string (mapping values are exact
12
+ * ref strings) plus a derived rename-aware name index.
13
+ * - Literals are stored PRE-ESCAPED (D8: `'` and `\` only) so the mapper never
14
+ * interpolates raw values into string literals.
15
+ * - Implicit variants (oneOf/anyOf `$ref` members absent from `mapping`) carry
16
+ * the RAW last ref segment as their literal (D3 precedence: mapping key >
17
+ * own `const` > single-value `enum` > raw segment).
18
+ *
19
+ * Field budget (D5): audited — every remaining field has a live consumer in
20
+ * production (seam / Variant-union emission) or in the registry unit tests.
21
+ * Rename-aware keying is mandatory: the name index goes through the same
22
+ * `buildSchemaRenameMap` machinery `analyze()` uses (see the past-bug note at
23
+ * schema-mapper.ts:75–78 — raw-name keying silently drops literal injection
24
+ * for renamed subtypes).
25
+ */
26
+ /** Rename-aware type-name derivation — the exact expression `analyze()` wires
27
+ * into the mapper's `typeNameGenerator` (`renameMap.get(raw) ?? sanitized`).
28
+ * Single source of truth so registry name keys can never diverge from the
29
+ * names the mapper emits for the same `$ref`.
30
+ */
31
+ export function renameAwareTypeName(renameMap, rawName) {
32
+ return renameMap.get(rawName) ?? sanitizeTypeName(rawName);
33
+ }
34
+ function isRefObject(obj) {
35
+ return (obj !== null &&
36
+ typeof obj === 'object' &&
37
+ !Array.isArray(obj) &&
38
+ '$ref' in obj);
39
+ }
40
+ /** Decode the last segment of a `$ref` string into its raw component key (`~1`/`~0` unescaped). */
41
+ function rawRefSegment(refStr) {
42
+ const segments = refStr.startsWith('#') ? parseJsonPointer(refStr.slice(1)) : refStr.split('/');
43
+ return segments[segments.length - 1] ?? refStr;
44
+ }
45
+ /** Encode a component key for embedding in a `#/components/schemas/…` ref string. */
46
+ function encodePointerSegment(segment) {
47
+ return segment.replace(/~/g, '~0').replace(/\//g, '~1');
48
+ }
49
+ /**
50
+ * A discriminator variant's own declared literal on the discriminator
51
+ * property: `const` first, then a single-value `enum` (D3 steps 2–3).
52
+ * Returns the string form; multi-value enums and absent props yield nothing.
53
+ */
54
+ function resolveOwnDiscriminatorLiteral(resolved, propertyName, resolver) {
55
+ if (!resolved)
56
+ return undefined;
57
+ let prop = resolved.properties?.[propertyName];
58
+ if (prop === undefined)
59
+ return undefined;
60
+ if (isRefObject(prop)) {
61
+ try {
62
+ prop = resolver.resolveSchema(prop);
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ if (prop.const !== undefined)
69
+ return { value: String(prop.const), source: 'const' };
70
+ if (prop.enum !== undefined && prop.enum.length === 1) {
71
+ return { value: String(prop.enum[0]), source: 'enum' };
72
+ }
73
+ return undefined;
74
+ }
75
+ /** Resolve a mapping/oneOf ref target for flag facts; unresolvable refs degrade to undefined (mapper-style try/catch). */
76
+ function resolveTargetSchema(resolver, refStr) {
77
+ try {
78
+ return resolver.resolveSchema({ $ref: refStr });
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }
84
+ /**
85
+ * Build the family-aware discriminator registry from the component schemas.
86
+ *
87
+ * Inputs must be the SAME `renameMap` / `allSchemaNames` `analyze()` derives
88
+ * (the rename-aware keying discipline) — pass `buildSchemaRenameMap`'s output
89
+ * and the emitted-name set, never a parallel sanitization. Pure with respect
90
+ * to the document: no mutation of the schemas; warnings (D3 mapping-key
91
+ * conflicts) go to `warnSink`.
92
+ */
93
+ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSchemaNames, warnSink) {
94
+ const warn = warnSink ??
95
+ ((message) => {
96
+ process.stderr.write(message);
97
+ });
98
+ const registry = {
99
+ families: new Map(),
100
+ byRef: new Map(),
101
+ byName: new Map(),
102
+ baseRefs: new Map(),
103
+ };
104
+ if (!schemas)
105
+ return registry;
106
+ // Pass 1 — discover families; per family, register mapping entries first
107
+ // (D3: mapping key wins), then implicit oneOf/anyOf variants absent from
108
+ // the mapping (literal = own const > single-value enum > RAW ref segment).
109
+ for (const [name, schema] of Object.entries(schemas)) {
110
+ const resolved = resolver.resolve(schema);
111
+ const disc = resolved.discriminator;
112
+ if (!disc)
113
+ continue;
114
+ const familyId = name;
115
+ const family = {
116
+ familyId,
117
+ baseTypeName: renameAwareTypeName(renameMap, name),
118
+ baseRefStr: `#/components/schemas/${encodePointerSegment(name)}`,
119
+ propertyName: disc.propertyName,
120
+ variantUnionName: '',
121
+ variantUnionMembers: [],
122
+ entriesByRef: new Map(),
123
+ };
124
+ const mappedRefStrs = new Set();
125
+ if (disc.mapping) {
126
+ for (const [mappingKey, refStr] of Object.entries(disc.mapping)) {
127
+ if (mappedRefStrs.has(refStr))
128
+ continue; // two keys → same target: first key wins
129
+ mappedRefStrs.add(refStr);
130
+ const target = resolveTargetSchema(resolver, refStr);
131
+ const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
132
+ if (own !== undefined && own.value !== mappingKey) {
133
+ warn(`Warning: Discriminator mapping key "${mappingKey}" for "${refStr}" conflicts with its own declared ${own.source} literal "${own.value}"; the mapping key wins.\n`);
134
+ }
135
+ const rawSegment = rawRefSegment(refStr);
136
+ const entry = {
137
+ propertyName: disc.propertyName,
138
+ literalValue: escapeStringLiteral(mappingKey),
139
+ isNamed: allSchemaNames.has(renameAwareTypeName(renameMap, rawSegment)),
140
+ isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
141
+ isNullable: target?.nullable === true ||
142
+ (Array.isArray(target?.type) ? target.type.includes('null') : false),
143
+ familyId,
144
+ refStr,
145
+ typeName: renameAwareTypeName(renameMap, rawSegment),
146
+ };
147
+ family.entriesByRef.set(refStr, entry);
148
+ }
149
+ }
150
+ const implicitRefs = [];
151
+ for (const member of [...(resolved.oneOf ?? []), ...(resolved.anyOf ?? [])]) {
152
+ if (isRefObject(member))
153
+ implicitRefs.push(member.$ref);
154
+ }
155
+ for (const refStr of implicitRefs) {
156
+ if (mappedRefStrs.has(refStr))
157
+ continue; // already registered via mapping
158
+ const target = resolveTargetSchema(resolver, refStr);
159
+ const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
160
+ const rawSegment = rawRefSegment(refStr);
161
+ const entry = {
162
+ propertyName: disc.propertyName,
163
+ // D3 fallback chain for unmapped variants: own const > single-value
164
+ // enum > RAW last ref segment (the runtime value, NOT the sanitized
165
+ // type name).
166
+ literalValue: escapeStringLiteral(own?.value ?? rawSegment),
167
+ isNamed: allSchemaNames.has(renameAwareTypeName(renameMap, rawSegment)),
168
+ isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
169
+ isNullable: target?.nullable === true ||
170
+ (Array.isArray(target?.type) ? target.type.includes('null') : false),
171
+ familyId,
172
+ refStr,
173
+ typeName: renameAwareTypeName(renameMap, rawSegment),
174
+ };
175
+ family.entriesByRef.set(refStr, entry);
176
+ }
177
+ registry.families.set(familyId, family);
178
+ }
179
+ // Pass 2 — flat lookups + per-family name index + union members
180
+ // (mapping order, then unmapped oneOf/anyOf refs; deduped by refStr and
181
+ // by typeName — alias refs to one emitted name collapse).
182
+ for (const family of registry.families.values()) {
183
+ const memberNames = [];
184
+ const seenNames = new Set();
185
+ for (const [refStr, entry] of family.entriesByRef) {
186
+ if (!registry.byRef.has(refStr))
187
+ registry.byRef.set(refStr, entry);
188
+ if (!registry.byName.has(entry.typeName))
189
+ registry.byName.set(entry.typeName, entry);
190
+ if (!seenNames.has(entry.typeName)) {
191
+ seenNames.add(entry.typeName);
192
+ memberNames.push(entry.typeName);
193
+ }
194
+ }
195
+ family.variantUnionMembers = memberNames;
196
+ registry.baseRefs.set(family.baseRefStr, family);
197
+ }
198
+ // Pass 3 — collision-safe `{Base}Variant` union names (D6): candidates go
199
+ // through the SAME reserved-name machinery as schemas, seeded with every
200
+ // emitted schema name, so a user schema literally named `ThingVariant`
201
+ // forces the generated union to `ThingVariantModel` (no duplicate export).
202
+ const unionCandidates = [...registry.families.values()].map((f) => `${f.baseTypeName}Variant`);
203
+ const unionRenames = buildSchemaRenameMap(unionCandidates, new Set([...RESERVED_TYPE_NAMES, ...allSchemaNames]));
204
+ for (const family of registry.families.values()) {
205
+ const candidate = `${family.baseTypeName}Variant`;
206
+ family.variantUnionName = unionRenames.get(candidate) ?? candidate;
207
+ }
208
+ return registry;
209
+ }
@@ -5,8 +5,8 @@ export interface AnalyzedParameter {
5
5
  name: string;
6
6
  in: 'path' | 'query' | 'header' | 'cookie';
7
7
  required: boolean;
8
+ /** Resolved param schema — analyzer-internal translation input for `analyze()`; renderers must not read it. */
8
9
  schema: SchemaObject | undefined;
9
- tsType: string;
10
10
  description?: string;
11
11
  deprecated?: boolean;
12
12
  example?: unknown;
@@ -14,18 +14,35 @@ export interface AnalyzedParameter {
14
14
  export interface AnalyzedRequestBody {
15
15
  required: boolean;
16
16
  contentTypes: string[];
17
- schema: SchemaObject | ReferenceObject | undefined;
18
- tsType: string;
17
+ /**
18
+ * Whether the first content entry carries a schema — drives the
19
+ * `{Prefix}Body` emission-presence decision (both the emission inventory in
20
+ * operation-naming and the Section-3 emission in analyze() read this fact,
21
+ * so they can never desync). The raw schema itself travels beside the
22
+ * operations array in `AnalyzedPathsResult`.
23
+ */
24
+ hasSchema: boolean;
19
25
  isMultipart: boolean;
26
+ isBinary: boolean;
20
27
  description?: string;
21
28
  }
22
29
  export interface AnalyzedResponse {
23
30
  statusCode: string;
24
31
  description?: string;
32
+ /** Raw (possibly `$ref`) first-content schema — analyzer-internal translation input for `analyze()`; renderers must not read it. */
25
33
  schema: SchemaObject | ReferenceObject | undefined;
26
- tsType: string;
27
34
  isSuccess: boolean;
28
35
  isBinary: boolean;
36
+ /**
37
+ * True exactly for schema-less, non-binary 2xx responses with no content
38
+ * (or empty content) — the "empty body → void" classification.
39
+ */
40
+ isVoid: boolean;
41
+ /**
42
+ * Finished (real-mapper) TS type text — StreamResponse substitution and
43
+ * discriminator variant rewrites applied. Set by `analyze()`.
44
+ */
45
+ finishedType?: string;
29
46
  }
30
47
  export interface AnalyzedOperation {
31
48
  method: string;
@@ -46,8 +63,28 @@ export interface AnalyzedOperation {
46
63
  responses: AnalyzedResponse[];
47
64
  }
48
65
  /**
49
- * Analyze all paths and operations from an OpenAPI document into structured data
50
- * for code generation.
66
+ * Analyzer-internal analysis result: the analyzed operations plus the raw
67
+ * request body schemas in a PARALLEL ARRAY (same length and order as
68
+ * `operations`). The raw schemas are translation input for `analyze()` and
69
+ * deliberately do not travel on the `AnalyzedRequestBody` seam.
70
+ */
71
+ export interface AnalyzedPathsResult {
72
+ operations: AnalyzedOperation[];
73
+ requestBodySchemas: (SchemaObject | ReferenceObject | undefined)[];
74
+ }
75
+ /**
76
+ * Analyze all paths and operations from an OpenAPI document into structured
77
+ * data for code generation, carrying the raw request body schemas alongside.
78
+ *
79
+ * @param doc - The parsed and validated OpenAPI document
80
+ * @param resolver - A RefResolver for resolving $ref pointers
81
+ * @param strategy - Method naming strategy (defaults to 'path-based')
82
+ * @returns Operations with parallel raw request body schemas
83
+ */
84
+ export declare function analyzePathsDetailed(doc: OpenAPIDocument, resolver: RefResolver, strategy?: MethodNameStrategy): AnalyzedPathsResult;
85
+ /**
86
+ * Analyze all paths and operations from an OpenAPI document into structured
87
+ * data for code generation.
51
88
  *
52
89
  * @param doc - The parsed and validated OpenAPI document
53
90
  * @param resolver - A RefResolver for resolving $ref pointers
@@ -1,63 +1,16 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
- import { sanitizeTypeName, getOperationTypePrefix } from '../utils/generator-helpers.js';
2
+ import { isBinaryContentType } from '../utils/generator-helpers.js';
3
+ import { getOperationTypePrefix } from '../utils/operation-naming.js';
3
4
  import { getMethodName } from './naming.js';
4
5
  const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
5
- function isRef(obj) {
6
- return obj !== null && typeof obj === 'object' && '$ref' in obj;
7
- }
8
- function isBinaryContentType(ct) {
9
- if (ct === 'application/octet-stream')
10
- return true;
11
- if (ct.startsWith('image/'))
12
- return true;
13
- if (ct.startsWith('video/'))
14
- return true;
15
- if (ct.startsWith('audio/'))
16
- return true;
17
- return false;
18
- }
19
- function schemaToTsType(schema, resolver) {
6
+ /**
7
+ * Resolve a possibly-$ref-ed schema and check for an exact top-level `format: binary`
8
+ * match. Top-level only — intentionally no deep-walk into items/allOf/oneOf/anyOf.
9
+ */
10
+ function isBinarySchema(schema, resolver) {
20
11
  if (!schema)
21
- return 'unknown';
22
- if (isRef(schema)) {
23
- const resolved = resolver.resolve(schema);
24
- const refStr = schema.$ref;
25
- const lastSegment = sanitizeTypeName(refStr.split('/').pop());
26
- if (lastSegment && resolved.type) {
27
- return lastSegment;
28
- }
29
- return lastSegment ?? 'unknown';
30
- }
31
- const s = schema;
32
- if (s.type === undefined)
33
- return 'unknown';
34
- if (Array.isArray(s.type)) {
35
- const nonNull = s.type.filter((t) => t !== 'null');
36
- if (nonNull.length === 0)
37
- return 'null';
38
- return schemaToTsType({ ...s, type: nonNull[0] }, resolver);
39
- }
40
- switch (s.type) {
41
- case 'string':
42
- return 'string';
43
- case 'integer':
44
- case 'number':
45
- return 'number';
46
- case 'boolean':
47
- return 'boolean';
48
- case 'array':
49
- if (s.items) {
50
- const itemType = schemaToTsType(s.items, resolver);
51
- return `${itemType}[]`;
52
- }
53
- return 'unknown[]';
54
- case 'object':
55
- return 'object';
56
- case 'null':
57
- return 'null';
58
- default:
59
- return 'unknown';
60
- }
12
+ return false;
13
+ return resolver.resolveSchema(schema).format === 'binary';
61
14
  }
62
15
  function resolveParameter(param, resolver) {
63
16
  return resolver.resolve(param);
@@ -75,7 +28,6 @@ function analyzeParameter(param, resolver) {
75
28
  in: param.in,
76
29
  required: param.required ?? param.in === 'path',
77
30
  schema,
78
- tsType: schemaToTsType(param.schema, resolver),
79
31
  description: param.description,
80
32
  deprecated: param.deprecated,
81
33
  example: param.example,
@@ -100,28 +52,40 @@ function mergeParameters(pathItemParams, operationParams, resolver) {
100
52
  }
101
53
  return merged;
102
54
  }
55
+ /**
56
+ * Analyze one request body. Returns the structural facts plus the RAW
57
+ * (possibly `$ref`) first-content schema as a separate value — the schema is
58
+ * analyzer-internal translation input for `analyze()` and does not travel on
59
+ * the `AnalyzedRequestBody` seam.
60
+ */
103
61
  function analyzeRequestBody(body, resolver) {
104
62
  if (!body)
105
63
  return undefined;
106
64
  const resolved = resolveRequestBody(body, resolver);
107
65
  const contentTypes = Object.keys(resolved.content);
108
66
  let schema;
109
- let tsType = 'unknown';
110
67
  if (contentTypes.length > 0) {
111
68
  const firstContent = resolved.content[contentTypes[0]];
112
69
  if (firstContent?.schema) {
113
70
  schema = firstContent.schema;
114
- tsType = schemaToTsType(schema, resolver);
115
71
  }
116
72
  }
117
73
  const isMultipart = contentTypes.length > 0 && contentTypes[0] === 'multipart/form-data';
74
+ // Contract with the generators: multipart bodies keep the FileInput shape
75
+ // and must never be Blob-ified, so the binary signals are bypassed.
76
+ const isBinary = isMultipart
77
+ ? false
78
+ : contentTypes.some(isBinaryContentType) || isBinarySchema(schema, resolver);
118
79
  return {
119
- required: resolved.required ?? false,
120
- contentTypes,
121
- schema,
122
- tsType,
123
- isMultipart,
124
- description: resolved.description,
80
+ requestBody: {
81
+ required: resolved.required ?? false,
82
+ contentTypes,
83
+ hasSchema: schema !== undefined,
84
+ isMultipart,
85
+ isBinary,
86
+ description: resolved.description,
87
+ },
88
+ rawSchema: schema,
125
89
  };
126
90
  }
127
91
  function analyzeResponses(responses, resolver) {
@@ -129,7 +93,6 @@ function analyzeResponses(responses, resolver) {
129
93
  for (const [statusCode, response] of Object.entries(responses)) {
130
94
  const resolved = resolveResponse(response, resolver);
131
95
  let schema;
132
- let tsType = 'unknown';
133
96
  let contentTypes = [];
134
97
  if (resolved.content) {
135
98
  contentTypes = Object.keys(resolved.content);
@@ -137,24 +100,22 @@ function analyzeResponses(responses, resolver) {
137
100
  const firstContent = resolved.content[contentTypes[0]];
138
101
  if (firstContent?.schema) {
139
102
  schema = firstContent.schema;
140
- tsType = schemaToTsType(schema, resolver);
141
103
  }
142
104
  }
143
105
  }
144
106
  // Empty-body success responses → void
145
- if (tsType === 'unknown' &&
107
+ const isVoid = schema === undefined &&
146
108
  statusCode.startsWith('2') &&
147
- (!resolved.content || contentTypes.length === 0)) {
148
- tsType = 'void';
149
- }
150
- const isBinary = contentTypes.length > 0 && isBinaryContentType(contentTypes[0]);
109
+ (!resolved.content || contentTypes.length === 0);
110
+ const isBinary = contentTypes.length > 0 &&
111
+ (isBinaryContentType(contentTypes[0]) || isBinarySchema(schema, resolver));
151
112
  result.push({
152
113
  statusCode,
153
114
  description: resolved.description,
154
115
  schema,
155
- tsType,
156
116
  isSuccess: statusCode.startsWith('2'),
157
117
  isBinary,
118
+ isVoid,
158
119
  });
159
120
  }
160
121
  return result;
@@ -183,18 +144,19 @@ function categorizeParameters(params) {
183
144
  return { pathParams, queryParams, headerParams, cookieParams };
184
145
  }
185
146
  /**
186
- * Analyze all paths and operations from an OpenAPI document into structured data
187
- * for code generation.
147
+ * Analyze all paths and operations from an OpenAPI document into structured
148
+ * data for code generation, carrying the raw request body schemas alongside.
188
149
  *
189
150
  * @param doc - The parsed and validated OpenAPI document
190
151
  * @param resolver - A RefResolver for resolving $ref pointers
191
152
  * @param strategy - Method naming strategy (defaults to 'path-based')
192
- * @returns Array of AnalyzedOperation objects
153
+ * @returns Operations with parallel raw request body schemas
193
154
  */
194
- export function analyzePaths(doc, resolver, strategy = 'path-based') {
155
+ export function analyzePathsDetailed(doc, resolver, strategy = 'path-based') {
195
156
  const operations = [];
157
+ const requestBodySchemas = [];
196
158
  if (!doc.paths)
197
- return operations;
159
+ return { operations, requestBodySchemas };
198
160
  const usedTypePrefixes = new Set();
199
161
  const usedMethodNames = new Set();
200
162
  for (const [urlPath, pathItem] of Object.entries(doc.paths)) {
@@ -205,7 +167,7 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
205
167
  const mergedParams = mergeParameters(pathItem.parameters, operation.parameters, resolver);
206
168
  const analyzedParams = mergedParams.map((p) => analyzeParameter(p, resolver));
207
169
  const categorized = categorizeParameters(analyzedParams);
208
- const requestBody = analyzeRequestBody(operation.requestBody, resolver);
170
+ const requestBodyResult = analyzeRequestBody(operation.requestBody, resolver);
209
171
  const responses = analyzeResponses(operation.responses, resolver);
210
172
  const methodName = getMethodName(method, urlPath, operation.operationId, strategy);
211
173
  operations.push({
@@ -218,17 +180,16 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
218
180
  deprecated: operation.deprecated ?? false,
219
181
  tags: operation.tags ?? [],
220
182
  ...categorized,
221
- requestBody,
183
+ requestBody: requestBodyResult?.requestBody,
222
184
  responses,
223
185
  });
186
+ requestBodySchemas.push(requestBodyResult?.rawSchema);
224
187
  }
225
188
  }
226
189
  // Dedupe type prefixes and method names so that distinct routes folding to
227
190
  // the same identifier (e.g. "/weird" and "/weird-", or "/Weird" and
228
191
  // "/weird") cannot produce duplicate exported types or client methods.
229
- // NOTE: analyzePaths runs twice per generation (contracts pass + client
230
- // pass); this assignment must stay deterministic - it relies on insertion
231
- // order and the fixed HTTP_METHODS loop - so both passes agree.
192
+ // Assignment relies on insertion order and the fixed HTTP_METHODS loop.
232
193
  for (const op of operations) {
233
194
  const baseTypePrefix = getOperationTypePrefix(op);
234
195
  let typePrefix = baseTypePrefix;
@@ -249,5 +210,17 @@ export function analyzePaths(doc, resolver, strategy = 'path-based') {
249
210
  usedMethodNames.add(methodName);
250
211
  op.methodName = methodName;
251
212
  }
252
- return operations;
213
+ return { operations, requestBodySchemas };
214
+ }
215
+ /**
216
+ * Analyze all paths and operations from an OpenAPI document into structured
217
+ * data for code generation.
218
+ *
219
+ * @param doc - The parsed and validated OpenAPI document
220
+ * @param resolver - A RefResolver for resolving $ref pointers
221
+ * @param strategy - Method naming strategy (defaults to 'path-based')
222
+ * @returns Array of AnalyzedOperation objects
223
+ */
224
+ export function analyzePaths(doc, resolver, strategy = 'path-based') {
225
+ return analyzePathsDetailed(doc, resolver, strategy).operations;
253
226
  }
@@ -1,10 +1,32 @@
1
1
  import { RefResolver } from '../parser/ref-resolver.js';
2
2
  import type { TypeMappingResult } from '../types/contracts.js';
3
3
  import type { SchemaObject, ReferenceObject } from '../types/openapi.js';
4
+ import type { DiscriminatorRegistry } from './discriminator-registry.js';
4
5
  /**
5
6
  * Callback to customize how $ref strings are converted to TypeScript type names.
6
7
  */
7
8
  export type TypeNameGenerator = (refString: string) => string;
9
+ /**
10
+ * Optional construction dependencies for {@link SchemaMapper}. Every field is
11
+ * optional and defaults exactly as the previous positional constructor
12
+ * parameters did.
13
+ */
14
+ export interface SchemaMapperOptions {
15
+ typeNameGenerator?: TypeNameGenerator;
16
+ discriminatorTargets?: Map<string, {
17
+ propertyName: string;
18
+ literalValue: string;
19
+ }>;
20
+ emittedNames?: Set<string>;
21
+ warnSink?: (message: string) => void;
22
+ discriminatorRegistry?: DiscriminatorRegistry;
23
+ /**
24
+ * Effective OpenAPI dialect. Omitted is treated as '3.1' (the nullable
25
+ * deprecation warning fires). Pass '3.0' to silence it — 'nullable' is the
26
+ * correct mechanism in 3.0.
27
+ */
28
+ effectiveVersion?: '3.0' | '3.1';
29
+ }
8
30
  /**
9
31
  * SchemaMapper converts OpenAPI 3.1 Schema Objects to TypeScript type strings.
10
32
  *
@@ -15,13 +37,13 @@ export declare class SchemaMapper {
15
37
  private readonly resolver;
16
38
  private readonly typeNameGenerator;
17
39
  private readonly discriminatorTargets;
18
- private readonly reservedNames;
40
+ private readonly emittedNames;
41
+ private readonly discriminatorRegistry;
19
42
  private readonly brandedTypes;
20
- private static nullableWarned;
21
- constructor(resolver: RefResolver, typeNameGenerator?: TypeNameGenerator, discriminatorTargets?: Map<string, {
22
- propertyName: string;
23
- literalValue: string;
24
- }>, reservedNames?: Set<string>);
43
+ private nullableWarned;
44
+ private readonly warnSink;
45
+ private readonly effectiveVersion;
46
+ constructor(resolver: RefResolver, opts?: SchemaMapperOptions);
25
47
  getBrandedTypes(): Map<string, {
26
48
  name: string;
27
49
  format: string;
@@ -36,12 +58,52 @@ export declare class SchemaMapper {
36
58
  * @returns TypeMappingResult with tsType string and imports array
37
59
  */
38
60
  mapSchema(schema: SchemaObject | ReferenceObject, name?: string, context?: 'request' | 'response'): TypeMappingResult;
61
+ /**
62
+ * Append the discriminator literal intersection exactly ONCE (site 2),
63
+ * parenthesizing a top-level union so `X | null & {...}` cannot bind the
64
+ * literal to the last union member only (D9).
65
+ */
66
+ private appendDiscriminatorLiteral;
39
67
  private getBrandTypeName;
40
68
  private mapInternal;
69
+ /**
70
+ * The single ref-translation seam (site 1). When the mapper carries a
71
+ * discriminator registry, EVERY `$ref` resolves through here:
72
+ *
73
+ * - a discriminator BASE becomes `{Base}Variant` at all sites except
74
+ * inside a same-family member's own definition spine, where it stays
75
+ * the bare base name (structural inheritance, no circularity — D1);
76
+ * - a same-family mapping target inside a spine becomes `Omit<M, 'P'>`,
77
+ * falling back to inline expansion WITHOUT the literal when M is a
78
+ * union, nullable, unnamed or non-object (D2/D4);
79
+ * - a mapping target at any other site is its bare name — the literal is
80
+ * injected exactly once, at the target's own named definition.
81
+ */
82
+ private translateRefThroughSeam;
83
+ /**
84
+ * D2/D4 spine rule for a same-family `$ref`. `Omit<M, 'P'>` strips the
85
+ * sibling's own literal so the appender can re-add ours; the inline-strip
86
+ * fallback covers the cases where `Omit` would silently drop data
87
+ * (union target: `Omit<A|B,K>` drops variant props; nullable target:
88
+ * `Omit<M|null,K>` collapses to `{}`; unnamed/non-object target).
89
+ */
90
+ private mapSameFamilySpineRef;
91
+ /** Normalize a `#/components/schemas/…` ref to its canonically-encoded form (`a/b` ↔ `a~1b`). */
92
+ private canonicalSchemasRef;
93
+ private tryResolveRef;
94
+ private isObjectIshSchema;
41
95
  private resolveDiscriminatorInfo;
42
96
  private mapEnumValues;
43
97
  private mapCombinator;
44
98
  private mapDiscriminatedUnion;
99
+ /**
100
+ * D3 literal precedence for registry-mode wrapper literals (inline variant
101
+ * schemas and foreign refs): mapping key > own `const` on the discriminant
102
+ * property > single-value `enum` > RAW last ref segment (the runtime
103
+ * value, NOT the sanitized type name). Values are escaped for a
104
+ * single-quoted TS string.
105
+ */
106
+ private resolveRegistryDiscriminantValue;
45
107
  private resolveDiscriminantValue;
46
108
  private mapArray;
47
109
  /**