genoc 0.5.1 → 0.5.3

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.
package/README.md CHANGED
@@ -58,10 +58,11 @@ import type { Requester } from 'genoc/runtime';
58
58
  For the full `Requester` type signature and binary and stream handling, see [Usage](./docs/usage.md).
59
59
 
60
60
  ```typescript
61
- import { createClient } from './client.js';
62
61
  import type { Requester } from 'genoc/runtime';
63
62
  import { RequesterFailError, ErrorResponse } from 'genoc/runtime';
64
63
 
64
+ import { createClient } from './client.js';
65
+
65
66
  const baseUrl = 'https://api.example.com';
66
67
 
67
68
  const requester: Requester = async (method, path, options) => {
@@ -3,7 +3,7 @@ import { buildSchemaRenameMap, buildTypeJsDoc, sanitizeJsDocText, sanitizeTypeNa
3
3
  import { operationEmissions, RESERVED_TYPE_NAMES } from '../utils/operation-naming.js';
4
4
  import { escapeStringLiteral } from '../utils/string.js';
5
5
  import { buildDiscriminatorRegistry, renameAwareTypeName } from './discriminator-registry.js';
6
- import { analyzePathsDetailed } from './path-analyzer.js';
6
+ import { analyzePathsDetailed, collectFormProperties, findBinaryFieldNames, schemaContainsBinary, } from './path-analyzer.js';
7
7
  import { SchemaMapper } from './schema-mapper.js';
8
8
  /**
9
9
  * Indent unit matching `INDENT_UNIT` in schema-mapper.ts (2 spaces, pinned by
@@ -338,6 +338,7 @@ function analyzeServers(servers) {
338
338
  export function analyze(doc, opts = {}) {
339
339
  const resolver = opts.resolver ?? new RefResolver(doc);
340
340
  const strategy = opts.strategy ?? 'path-based';
341
+ const warn = opts.warnSink ?? writeWarning;
341
342
  const schemaNameList = doc.components?.schemas ? Object.keys(doc.components.schemas) : [];
342
343
  const renameMap = buildSchemaRenameMap(schemaNameList, RESERVED_TYPE_NAMES);
343
344
  for (const [original, renamed] of renameMap) {
@@ -360,7 +361,7 @@ export function analyze(doc, opts = {}) {
360
361
  // source for discriminator translation: the mapper's ref-translation seam
361
362
  // consumes it, and the always-emitted `{Base}Variant` unions below are
362
363
  // emitted from it.
363
- const discriminatorRegistry = buildDiscriminatorRegistry(doc.components?.schemas, resolver, renameMap, allSchemaNames, writeWarning);
364
+ const discriminatorRegistry = buildDiscriminatorRegistry(doc.components?.schemas, resolver, renameMap, allSchemaNames, warn);
364
365
  const mapper = new SchemaMapper(resolver, {
365
366
  typeNameGenerator: renamingTypeGenerator,
366
367
  emittedNames: allSchemaNames,
@@ -472,14 +473,28 @@ export function analyze(doc, opts = {}) {
472
473
  if (emissions.headers) {
473
474
  opLines.push(`export type ${emissions.headers} = ${buildParamTypeBody(op.headerParams, mapper)};`);
474
475
  }
476
+ // A urlencoded body declaring `format: binary` fields cannot transport
477
+ // them — the generated client would silently type the files as plain
478
+ // strings. Warn and name the offending fields (merged through allOf).
479
+ if (op.requestBody?.contentTypes[0] === 'application/x-www-form-urlencoded' &&
480
+ rawBodySchema !== undefined &&
481
+ schemaContainsBinary(rawBodySchema, resolver)) {
482
+ const fieldNames = findBinaryFieldNames(rawBodySchema, resolver);
483
+ const fieldList = fieldNames.length > 0
484
+ ? ` declaring binary fields (${fieldNames.map((n) => `"${n}"`).join(', ')})`
485
+ : ' declaring binary data';
486
+ warn(`Warning: the request body of ${op.method.toUpperCase()} ${op.path} uses content type "application/x-www-form-urlencoded"${fieldList}, but urlencoded form data cannot transport binary files. File uploads require "multipart/form-data"; the generated client types these fields as plain strings.\n`);
487
+ }
475
488
  // Section 3: Request body types
476
489
  const bodyJsDoc = op.requestBody ? buildDescriptionJsDoc(op.requestBody.description) : '';
477
490
  if (op.requestBody?.isMultipart && rawBodySchema !== undefined) {
478
- const schema = resolver.resolveSchema(rawBodySchema);
479
- const requiredSet = new Set(schema.required ?? []);
491
+ // Form fields come from the allOf-merged walk: a body declaring its
492
+ // fields via `allOf` members (resolving `$ref`s) previously produced an
493
+ // empty property walk — `Record<string, never>` and dropped appends.
494
+ const { properties, required: requiredSet } = collectFormProperties(rawBodySchema, resolver);
480
495
  const propLines = [];
481
496
  const fileUploadProperties = [];
482
- for (const [name, propSchema] of Object.entries(schema.properties ?? {})) {
497
+ for (const [name, propSchema] of properties) {
483
498
  const resolved = resolver.resolveSchema(propSchema);
484
499
  const optional = requiredSet.has(name) ? '' : '?';
485
500
  let tsType;
@@ -49,10 +49,18 @@ export interface DiscriminatorVariantEntry {
49
49
  isNullable: boolean;
50
50
  /** Owning family id — the discriminator base's raw component name. */
51
51
  familyId: string;
52
- /** The exact `$ref` string that produced this entry (mapping value or oneOf/anyOf member ref). */
52
+ /** The exact `$ref` string that produced this entry (mapping value, oneOf/anyOf member ref, canonical base ref, or implicit allOf child ref). */
53
53
  refStr: string;
54
54
  /** Rename-aware type name for the ref (same derivation as the mapper's `typeNameGenerator`). */
55
55
  typeName: string;
56
+ /**
57
+ * True for the family base's own entry — the base participates in its own
58
+ * family union (self-mapped, or no `oneOf`/`anyOf` of its own). Consumers:
59
+ * same-family-spine base-ref translation routes through the entry seam
60
+ * (`Omit<Base, P>`), and the mapper skips the `{ P: string }` placeholder on
61
+ * the base's own definition because the literal replaces it.
62
+ */
63
+ isBaseMember: boolean;
56
64
  }
57
65
  /**
58
66
  * One discriminator family: a component schema declaring `discriminator`
@@ -73,7 +81,7 @@ export interface DiscriminatorFamily {
73
81
  propertyName: string;
74
82
  /** Collision-safe `{Base}Variant` union name (D6 — via the reserved-name machinery). */
75
83
  variantUnionName: string;
76
- /** Union member type names (rename-aware, deduped; mapping order, then unmapped oneOf/anyOf refs). */
84
+ /** Union member type names (rename-aware, deduped; entry insertion order: base, mapping, unmapped oneOf/anyOf refs, implicit allOf children). */
77
85
  variantUnionMembers: string[];
78
86
  /** Family entries keyed by the FULL `$ref` string (mapping value or oneOf/anyOf member ref). */
79
87
  entriesByRef: Map<string, DiscriminatorVariantEntry>;
@@ -103,9 +103,36 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
103
103
  };
104
104
  if (!schemas)
105
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).
106
+ // Implicit allOf children (issue #93): a schema whose `allOf` references a
107
+ // family base DIRECTLY is a variant of that family even when `mapping` and
108
+ // the base's own oneOf/anyOf leave it out. Index childName → the set of
109
+ // canonical `#/components/schemas/…` refs it allOfs, once, up front —
110
+ // grandchildren (allOf a child, not the base) stay out by construction.
111
+ const allOfBasesByChild = new Map();
112
+ for (const [childName, childSchema] of Object.entries(schemas)) {
113
+ let childResolved;
114
+ try {
115
+ childResolved = resolver.resolve(childSchema);
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ if (!childResolved.allOf)
121
+ continue;
122
+ const bases = new Set();
123
+ for (const member of childResolved.allOf) {
124
+ if (!isRefObject(member) || !member.$ref.startsWith('#/components/schemas/'))
125
+ continue;
126
+ bases.add(`#/components/schemas/${encodePointerSegment(rawRefSegment(member.$ref))}`);
127
+ }
128
+ if (bases.size > 0)
129
+ allOfBasesByChild.set(childName, bases);
130
+ }
131
+ // Pass 1 — discover families; per family, register the base's own entry
132
+ // when it joins its family (self-mapped, or no oneOf/anyOf of its own),
133
+ // then mapping entries (D3: mapping key wins), then implicit oneOf/anyOf
134
+ // variants absent from the mapping (literal = own const > single-value
135
+ // enum > RAW ref segment), then implicit allOf children absent from both.
109
136
  for (const [name, schema] of Object.entries(schemas)) {
110
137
  const resolved = resolver.resolve(schema);
111
138
  const disc = resolved.discriminator;
@@ -121,12 +148,46 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
121
148
  variantUnionMembers: [],
122
149
  entriesByRef: new Map(),
123
150
  };
151
+ // Base membership (#93): a CONCRETE base (no oneOf/anyOf of its own) is a
152
+ // variant of its own family even when `mapping` omits it — with no other
153
+ // members the family would otherwise reference an empty union. A base
154
+ // listed in its own mapping joins too (its key wins the literal, D3).
155
+ // Insertion order matters: the base entry lands FIRST so union members
156
+ // read `{Base} | <mapped…> | <implicit…>`.
157
+ const baseHasOwnUnion = (resolved.oneOf?.length ?? 0) > 0 || (resolved.anyOf?.length ?? 0) > 0;
158
+ const selfMappingKey = disc.mapping
159
+ ? Object.entries(disc.mapping).find(([, refStr]) => refStr === family.baseRefStr)?.[0]
160
+ : undefined;
161
+ if (selfMappingKey !== undefined || !baseHasOwnUnion) {
162
+ const ownBaseLiteral = resolveOwnDiscriminatorLiteral(resolved, disc.propertyName, resolver);
163
+ if (selfMappingKey !== undefined &&
164
+ ownBaseLiteral !== undefined &&
165
+ ownBaseLiteral.value !== selfMappingKey) {
166
+ warn(`Warning: Discriminator mapping key "${selfMappingKey}" for "${family.baseRefStr}" conflicts with its own declared ${ownBaseLiteral.source} literal "${ownBaseLiteral.value}"; the mapping key wins.\n`);
167
+ }
168
+ family.entriesByRef.set(family.baseRefStr, {
169
+ propertyName: disc.propertyName,
170
+ // Base literal precedence: self-mapping key > own const/enum > raw
171
+ // component name (the runtime value, NOT the sanitized type name).
172
+ literalValue: escapeStringLiteral(selfMappingKey ?? ownBaseLiteral?.value ?? name),
173
+ isNamed: true, // the base is by definition an emitted component schema
174
+ isUnion: baseHasOwnUnion,
175
+ isNullable: resolved.nullable === true ||
176
+ (Array.isArray(resolved.type) ? resolved.type.includes('null') : false),
177
+ isBaseMember: true,
178
+ familyId,
179
+ refStr: family.baseRefStr,
180
+ typeName: family.baseTypeName,
181
+ });
182
+ }
124
183
  const mappedRefStrs = new Set();
125
184
  if (disc.mapping) {
126
185
  for (const [mappingKey, refStr] of Object.entries(disc.mapping)) {
127
186
  if (mappedRefStrs.has(refStr))
128
187
  continue; // two keys → same target: first key wins
129
188
  mappedRefStrs.add(refStr);
189
+ if (family.entriesByRef.has(refStr))
190
+ continue; // base self-mapping — the base entry already carries it
130
191
  const target = resolveTargetSchema(resolver, refStr);
131
192
  const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
132
193
  if (own !== undefined && own.value !== mappingKey) {
@@ -140,6 +201,7 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
140
201
  isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
141
202
  isNullable: target?.nullable === true ||
142
203
  (Array.isArray(target?.type) ? target.type.includes('null') : false),
204
+ isBaseMember: false,
143
205
  familyId,
144
206
  refStr,
145
207
  typeName: renameAwareTypeName(renameMap, rawSegment),
@@ -153,8 +215,8 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
153
215
  implicitRefs.push(member.$ref);
154
216
  }
155
217
  for (const refStr of implicitRefs) {
156
- if (mappedRefStrs.has(refStr))
157
- continue; // already registered via mapping
218
+ if (mappedRefStrs.has(refStr) || family.entriesByRef.has(refStr))
219
+ continue; // already registered
158
220
  const target = resolveTargetSchema(resolver, refStr);
159
221
  const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
160
222
  const rawSegment = rawRefSegment(refStr);
@@ -168,17 +230,46 @@ export function buildDiscriminatorRegistry(schemas, resolver, renameMap, allSche
168
230
  isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
169
231
  isNullable: target?.nullable === true ||
170
232
  (Array.isArray(target?.type) ? target.type.includes('null') : false),
233
+ isBaseMember: false,
171
234
  familyId,
172
235
  refStr,
173
236
  typeName: renameAwareTypeName(renameMap, rawSegment),
174
237
  };
175
238
  family.entriesByRef.set(refStr, entry);
176
239
  }
240
+ // Implicit allOf children (#93): direct `allOf` refs to the base count as
241
+ // variants even when `mapping` leaves them out; anything already present
242
+ // (mapping target, implicit oneOf/anyOf ref, the base itself) is skipped,
243
+ // so children land in component order after the mapped/implicit members.
244
+ for (const [childName, bases] of allOfBasesByChild) {
245
+ if (!bases.has(family.baseRefStr))
246
+ continue;
247
+ const childRefStr = `#/components/schemas/${encodePointerSegment(childName)}`;
248
+ if (family.entriesByRef.has(childRefStr))
249
+ continue;
250
+ const target = resolveTargetSchema(resolver, childRefStr);
251
+ const own = resolveOwnDiscriminatorLiteral(target, disc.propertyName, resolver);
252
+ const entry = {
253
+ propertyName: disc.propertyName,
254
+ // Same D3 fallback chain as unmapped oneOf/anyOf variants.
255
+ literalValue: escapeStringLiteral(own?.value ?? childName),
256
+ isNamed: allSchemaNames.has(renameAwareTypeName(renameMap, childName)),
257
+ isUnion: (target?.oneOf?.length ?? 0) > 0 || (target?.anyOf?.length ?? 0) > 0,
258
+ isNullable: target?.nullable === true ||
259
+ (Array.isArray(target?.type) ? target.type.includes('null') : false),
260
+ isBaseMember: false,
261
+ familyId,
262
+ refStr: childRefStr,
263
+ typeName: renameAwareTypeName(renameMap, childName),
264
+ };
265
+ family.entriesByRef.set(childRefStr, entry);
266
+ }
177
267
  registry.families.set(familyId, family);
178
268
  }
179
269
  // 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).
270
+ // (entry insertion order: base, then mapping, then unmapped oneOf/anyOf
271
+ // refs, then implicit allOf children; deduped by refStr and by typeName
272
+ // — alias refs to one emitted name collapse).
182
273
  for (const family of registry.families.values()) {
183
274
  const memberNames = [];
184
275
  const seenNames = new Set();
@@ -82,6 +82,37 @@ export interface AnalyzedOperation {
82
82
  /** Raw operation-level `security`; `undefined` = inherit the document default. */
83
83
  security?: SecurityRequirementObject[];
84
84
  }
85
+ /**
86
+ * Deep, ref-resolving, cycle-safe check: does any schema node reachable from
87
+ * `schema` (through `properties`, `items`, `allOf`/`oneOf`/`anyOf` members and
88
+ * schema-valued `additionalProperties`) declare `format: binary`? Unlike
89
+ * {@link isBinarySchema} this catches binary data buried in composed schemas —
90
+ * e.g. a `files` array of binary strings hidden inside an `allOf` member.
91
+ */
92
+ export declare function schemaContainsBinary(schema: SchemaObject | ReferenceObject | undefined, resolver: RefResolver): boolean;
93
+ /**
94
+ * The flattened form fields of a request body schema: the root object's own
95
+ * `properties`/`required` merged with those of every `allOf` member,
96
+ * recursively, resolving `$ref`s. Later same-named members do not override
97
+ * earlier ones (first-write wins, matching the mapper's `allOf` array-order
98
+ * rendering); member `required` lists are unioned.
99
+ *
100
+ * Motivation: multipart bodies declaring their fields via `allOf` (a common
101
+ * upstream-spec shape) previously produced an empty property walk — the
102
+ * generated body type collapsed to `Record<string, never>` and every FormData
103
+ * append was silently dropped.
104
+ */
105
+ export declare function collectFormProperties(schema: SchemaObject | ReferenceObject | undefined, resolver: RefResolver, properties?: Map<string, SchemaObject | ReferenceObject>, required?: Set<string>, visited?: Set<SchemaObject>): {
106
+ properties: Map<string, SchemaObject | ReferenceObject>;
107
+ required: Set<string>;
108
+ };
109
+ /**
110
+ * Names of the (allOf-merged) top-level form fields whose subtree contains
111
+ * `format: binary` — for warnings that name the offending fields. Empty when
112
+ * the binary data sits outside any named field (e.g. the whole body schema
113
+ * is a binary string) or there is no binary data at all.
114
+ */
115
+ export declare function findBinaryFieldNames(schema: SchemaObject | ReferenceObject | undefined, resolver: RefResolver): string[];
85
116
  /**
86
117
  * Analyzer-internal analysis result: the analyzed operations plus the raw
87
118
  * request body schemas in a PARALLEL ARRAY (same length and order as
@@ -12,6 +12,103 @@ function isBinarySchema(schema, resolver) {
12
12
  return false;
13
13
  return resolver.resolveSchema(schema).format === 'binary';
14
14
  }
15
+ /**
16
+ * Deep, ref-resolving, cycle-safe check: does any schema node reachable from
17
+ * `schema` (through `properties`, `items`, `allOf`/`oneOf`/`anyOf` members and
18
+ * schema-valued `additionalProperties`) declare `format: binary`? Unlike
19
+ * {@link isBinarySchema} this catches binary data buried in composed schemas —
20
+ * e.g. a `files` array of binary strings hidden inside an `allOf` member.
21
+ */
22
+ export function schemaContainsBinary(schema, resolver) {
23
+ return containsBinary(schema, resolver, new Set());
24
+ }
25
+ function containsBinary(schema, resolver, visited) {
26
+ if (!schema)
27
+ return false;
28
+ const resolved = resolver.resolveSchema(schema);
29
+ // Cycle guard on resolved object identity (a self-referencing $ref chain
30
+ // would otherwise recurse forever; schema-mapper uses the same pattern).
31
+ if (visited.has(resolved))
32
+ return false;
33
+ visited.add(resolved);
34
+ if (resolved.format === 'binary')
35
+ return true;
36
+ if (resolved.items !== undefined) {
37
+ if (containsBinary(resolved.items, resolver, visited))
38
+ return true;
39
+ }
40
+ for (const member of resolved.allOf ?? []) {
41
+ if (containsBinary(member, resolver, visited))
42
+ return true;
43
+ }
44
+ for (const member of resolved.oneOf ?? []) {
45
+ if (containsBinary(member, resolver, visited))
46
+ return true;
47
+ }
48
+ for (const member of resolved.anyOf ?? []) {
49
+ if (containsBinary(member, resolver, visited))
50
+ return true;
51
+ }
52
+ for (const propSchema of Object.values(resolved.properties ?? {})) {
53
+ if (containsBinary(propSchema, resolver, visited))
54
+ return true;
55
+ }
56
+ if (typeof resolved.additionalProperties === 'object' && resolved.additionalProperties !== null) {
57
+ if (containsBinary(resolved.additionalProperties, resolver, visited))
58
+ return true;
59
+ }
60
+ return false;
61
+ }
62
+ /**
63
+ * The flattened form fields of a request body schema: the root object's own
64
+ * `properties`/`required` merged with those of every `allOf` member,
65
+ * recursively, resolving `$ref`s. Later same-named members do not override
66
+ * earlier ones (first-write wins, matching the mapper's `allOf` array-order
67
+ * rendering); member `required` lists are unioned.
68
+ *
69
+ * Motivation: multipart bodies declaring their fields via `allOf` (a common
70
+ * upstream-spec shape) previously produced an empty property walk — the
71
+ * generated body type collapsed to `Record<string, never>` and every FormData
72
+ * append was silently dropped.
73
+ */
74
+ export function collectFormProperties(schema, resolver, properties = new Map(), required = new Set(), visited = new Set()) {
75
+ if (!schema)
76
+ return { properties, required };
77
+ const resolved = resolver.resolveSchema(schema);
78
+ // Cycle guard on resolved identity (same pattern as containsBinary above).
79
+ if (visited.has(resolved))
80
+ return { properties, required };
81
+ visited.add(resolved);
82
+ // allOf members come first, mirroring the mapper's `A & B` member order.
83
+ for (const member of resolved.allOf ?? []) {
84
+ collectFormProperties(member, resolver, properties, required, visited);
85
+ }
86
+ for (const name of resolved.required ?? []) {
87
+ required.add(name);
88
+ }
89
+ for (const [name, propSchema] of Object.entries(resolved.properties ?? {})) {
90
+ if (!properties.has(name)) {
91
+ properties.set(name, propSchema);
92
+ }
93
+ }
94
+ return { properties, required };
95
+ }
96
+ /**
97
+ * Names of the (allOf-merged) top-level form fields whose subtree contains
98
+ * `format: binary` — for warnings that name the offending fields. Empty when
99
+ * the binary data sits outside any named field (e.g. the whole body schema
100
+ * is a binary string) or there is no binary data at all.
101
+ */
102
+ export function findBinaryFieldNames(schema, resolver) {
103
+ const { properties } = collectFormProperties(schema, resolver);
104
+ const names = [];
105
+ for (const [name, propSchema] of properties) {
106
+ if (containsBinary(propSchema, resolver, new Set())) {
107
+ names.push(name);
108
+ }
109
+ }
110
+ return names;
111
+ }
15
112
  function resolveParameter(param, resolver) {
16
113
  return resolver.resolve(param);
17
114
  }
@@ -76,6 +76,7 @@ function spineForEntry(entry) {
76
76
  familyId: entry.familyId,
77
77
  propertyName: entry.propertyName,
78
78
  literalValue: entry.literalValue,
79
+ isBaseMember: entry.isBaseMember,
79
80
  };
80
81
  }
81
82
  /**
@@ -266,6 +267,14 @@ export class SchemaMapper {
266
267
  return result;
267
268
  }
268
269
  if (s.type === undefined) {
270
+ // OpenAPI permits omitting `type`: a schema carrying `properties`
271
+ // and/or `additionalProperties` is an object schema regardless. Map it
272
+ // through the object branch instead of degrading to `unknown`; a
273
+ // genuinely unconstrained schema (no keywords to infer from) stays
274
+ // `unknown` (any JSON value).
275
+ if (s.properties !== undefined || s.additionalProperties !== undefined) {
276
+ return this.mapObject(s, name, context, visited, indent, spine);
277
+ }
269
278
  return { tsType: 'unknown', imports: [] };
270
279
  }
271
280
  if (Array.isArray(s.type)) {
@@ -354,9 +363,21 @@ export class SchemaMapper {
354
363
  const canonical = this.canonicalSchemasRef(refStr);
355
364
  const baseFamily = registry.baseRefs.get(refStr) ?? registry.baseRefs.get(canonical);
356
365
  if (baseFamily) {
366
+ const baseEntry = baseFamily.entriesByRef.get(baseFamily.baseRefStr);
367
+ if (spine && spine.familyId === baseFamily.familyId && baseEntry) {
368
+ // Member base inside its own family spine (#93): the base joins as a
369
+ // variant through the entry seam — `Omit<Base, P>` — so the child's
370
+ // literal append never collides with the base's own.
371
+ return this.mapSameFamilySpineRef(baseEntry, context, visited, indent);
372
+ }
357
373
  if (spine && spine.familyId === baseFamily.familyId) {
358
374
  return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
359
375
  }
376
+ // Empty family (#93): no members at all — refs fall back to the bare
377
+ // base name instead of an undeclared `{Base}Variant` union.
378
+ if (baseFamily.variantUnionMembers.length === 0) {
379
+ return { tsType: baseFamily.baseTypeName, imports: [baseFamily.baseTypeName] };
380
+ }
360
381
  return { tsType: baseFamily.variantUnionName, imports: [baseFamily.variantUnionName] };
361
382
  }
362
383
  const entry = registry.byRef.get(refStr) ?? registry.byRef.get(canonical);
@@ -650,7 +671,9 @@ export class SchemaMapper {
650
671
  const memberIndent = indentBy(indent + 1);
651
672
  const memberLines = [];
652
673
  const allImports = [];
653
- if (schema.discriminator) {
674
+ if (schema.discriminator && !spine?.isBaseMember) {
675
+ // A member base's placeholder (`"${discPropName}": string;`) is skipped:
676
+ // its literal append replaces the declared property outright (#93).
654
677
  const discPropName = schema.discriminator.propertyName;
655
678
  const discIndex = filteredPropNames.indexOf(discPropName);
656
679
  if (discIndex !== -1) {
@@ -148,4 +148,6 @@ export interface AnalyzeOptions {
148
148
  * Omitted is treated as `'3.1'`.
149
149
  */
150
150
  effectiveVersion?: '3.0' | '3.1';
151
+ /** Warning sink; defaults to `process.stderr.write`. Mirrors the mapper's `warnSink`. */
152
+ warnSink?: (message: string) => void;
151
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genoc",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Generate typed HTTP clients from OpenAPI 3.0 / 3.1 specifications",
5
5
  "keywords": [
6
6
  "client",
@@ -48,12 +48,12 @@
48
48
  },
49
49
  "devDependencies": {
50
50
  "@changesets/cli": "^2.31.0",
51
- "@types/node": "^26.6.2",
52
- "oxfmt": "^0.70.0",
53
- "oxlint": "^1.85.0",
51
+ "@types/node": "^26.6.5",
52
+ "oxfmt": "^0.75.0",
53
+ "oxlint": "^1.90.0",
54
54
  "typescript": "^6.0.3",
55
- "undici": "^8",
56
- "vitest": "^5.0.1"
55
+ "undici": "^8.12.3",
56
+ "vitest": "^5.0.5"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "undici": "^6.13.0 || ^7 || ^8"