@workos/oagen 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -252,17 +252,26 @@ function buildInlineModel(name, schema) {
252
252
 
253
253
  // src/parser/schemas.ts
254
254
  function extractSchemas(schemas) {
255
- const models = [];
256
255
  const enums = [];
257
- if (!schemas) return { models, enums };
256
+ if (!schemas) return { models: [], enums };
257
+ const modelsByCleanName = /* @__PURE__ */ new Map();
258
258
  for (const [name, schema] of Object.entries(schemas)) {
259
259
  const pascalName = cleanSchemaName(toPascalCase(name));
260
260
  if (schema.enum) {
261
261
  enums.push(extractEnum(pascalName, schema));
262
262
  } else {
263
- models.push(extractModel(pascalName, schema, schemas));
263
+ const model = extractModel(pascalName, schema, schemas);
264
+ const existing = modelsByCleanName.get(pascalName);
265
+ if (existing) {
266
+ if (model.fields.length > existing.fields.length) {
267
+ modelsByCleanName.set(pascalName, model);
268
+ }
269
+ } else {
270
+ modelsByCleanName.set(pascalName, model);
271
+ }
264
272
  }
265
273
  }
274
+ const models = [...modelsByCleanName.values()];
266
275
  const modelNames = new Set(models.map((m) => m.name));
267
276
  const inlineQueue = [...models];
268
277
  while (inlineQueue.length > 0) {
@@ -692,6 +701,18 @@ function classifyAndExtractResponse(schema, contextName) {
692
701
  }
693
702
  return extractDirectResource(schema, contextName);
694
703
  }
704
+ var KNOWN_DATA_PATHS = /* @__PURE__ */ new Set(["data", "items", "results", "records", "entries", "values", "nodes", "edges"]);
705
+ var PAGINATION_METADATA_PATTERNS = [
706
+ "metadata",
707
+ "pagination",
708
+ "cursor",
709
+ "has_more",
710
+ "page_info",
711
+ "total",
712
+ "next_page",
713
+ "previous_page",
714
+ "offset"
715
+ ];
695
716
  function detectListEnvelope(schema) {
696
717
  const propSources = [];
697
718
  if (schema.allOf) {
@@ -708,17 +729,26 @@ function detectListEnvelope(schema) {
708
729
  Object.assign(mergedProps, source);
709
730
  }
710
731
  const arrayProps = [];
711
- let nonArrayCount = 0;
732
+ const nonArrayKeys = [];
712
733
  for (const [key, propSchema] of Object.entries(mergedProps)) {
713
734
  if (!propSchema) continue;
714
735
  if (propSchema.type === "array") {
715
736
  arrayProps.push(key);
716
737
  } else {
717
- nonArrayCount++;
738
+ nonArrayKeys.push(key);
718
739
  }
719
740
  }
720
- if (arrayProps.length === 1 && nonArrayCount >= 1) {
721
- return { isEnvelope: true, dataPath: arrayProps[0] };
741
+ if (arrayProps.length === 1 && nonArrayKeys.length >= 1) {
742
+ const arrayPropName = arrayProps[0];
743
+ if (KNOWN_DATA_PATHS.has(arrayPropName)) {
744
+ return { isEnvelope: true, dataPath: arrayPropName };
745
+ }
746
+ const hasPaginationCompanion = nonArrayKeys.some(
747
+ (key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern))
748
+ );
749
+ if (hasPaginationCompanion) {
750
+ return { isEnvelope: true, dataPath: arrayPropName };
751
+ }
722
752
  }
723
753
  return { isEnvelope: false, dataPath: null };
724
754
  }
@@ -1355,20 +1385,24 @@ function keepLargestByName(models) {
1355
1385
  }
1356
1386
  return [...byName.values()];
1357
1387
  }
1358
- function rewriteModelRefsInServices(services, oldName, newName) {
1388
+ function rewriteModelRef(ref, oldName, newName) {
1389
+ walkTypeRef(ref, {
1390
+ model: (r) => {
1391
+ if (r.name === oldName) r.name = newName;
1392
+ }
1393
+ });
1394
+ }
1395
+ function rewriteModelRefs(models, services, oldName, newName) {
1396
+ for (const model of models) {
1397
+ for (const field of model.fields) {
1398
+ rewriteModelRef(field.type, oldName, newName);
1399
+ }
1400
+ }
1359
1401
  for (const service of services) {
1360
1402
  for (const op of service.operations) {
1361
- walkTypeRef(op.response, {
1362
- model: (r) => {
1363
- if (r.name === oldName) r.name = newName;
1364
- }
1365
- });
1403
+ rewriteModelRef(op.response, oldName, newName);
1366
1404
  if (op.requestBody) {
1367
- walkTypeRef(op.requestBody, {
1368
- model: (r) => {
1369
- if (r.name === oldName) r.name = newName;
1370
- }
1371
- });
1405
+ rewriteModelRef(op.requestBody, oldName, newName);
1372
1406
  }
1373
1407
  }
1374
1408
  }
@@ -1417,9 +1451,30 @@ function mergeFieldInlineModels(existingModels, fieldInlineModels) {
1417
1451
  });
1418
1452
  return [...existingModels, ...keepLargestByName(deduplicatedFieldModels)];
1419
1453
  }
1454
+ function hasModelConstDiscriminant(model) {
1455
+ return model.fields.some(
1456
+ (f) => (f.name === "object" || f.name === "type") && f.type.kind === "literal" && typeof f.type.value === "string"
1457
+ );
1458
+ }
1459
+ function isModelReferencedByOthers(name, models, excludeNames) {
1460
+ for (const model of models) {
1461
+ if (excludeNames.has(model.name)) continue;
1462
+ for (const field of model.fields) {
1463
+ let found = false;
1464
+ walkTypeRef(field.type, {
1465
+ model: (r) => {
1466
+ if (r.name === name) found = true;
1467
+ }
1468
+ });
1469
+ if (found) return true;
1470
+ }
1471
+ }
1472
+ return false;
1473
+ }
1420
1474
  function collapseJsonSuffixModels(models, services) {
1421
1475
  const normalizedModels = [...models];
1422
1476
  const byName = new Map(normalizedModels.map((m) => [m.name, m]));
1477
+ const mergeCandidates = [];
1423
1478
  for (const model of normalizedModels) {
1424
1479
  if (!model.name.endsWith("Json")) continue;
1425
1480
  const baseName = model.name.slice(0, -4);
@@ -1427,13 +1482,20 @@ function collapseJsonSuffixModels(models, services) {
1427
1482
  if (!baseModel || model.fields.length <= baseModel.fields.length) continue;
1428
1483
  const isSuperset = baseModel.fields.every((f) => model.fields.some((mf) => mf.name === f.name));
1429
1484
  if (!isSuperset) continue;
1430
- baseModel.fields = model.fields;
1431
- const jsonIdx = normalizedModels.indexOf(model);
1432
- if (jsonIdx !== -1) normalizedModels.splice(jsonIdx, 1);
1433
- rewriteModelRefsInServices(services, model.name, baseName);
1434
- console.warn(`[oagen] Merged "${model.name}" into "${baseName}" (${model.fields.length} fields, superset)`);
1485
+ if (hasModelConstDiscriminant(model) && hasModelConstDiscriminant(baseModel)) continue;
1486
+ if (isModelReferencedByOthers(model.name, normalizedModels, /* @__PURE__ */ new Set([model.name, baseName]))) continue;
1487
+ mergeCandidates.push({ jsonModel: model, baseModel });
1488
+ }
1489
+ const toRemove = /* @__PURE__ */ new Set();
1490
+ for (const { jsonModel, baseModel } of mergeCandidates) {
1491
+ baseModel.fields = jsonModel.fields;
1492
+ toRemove.add(jsonModel.name);
1493
+ rewriteModelRefs(normalizedModels, services, jsonModel.name, baseModel.name);
1494
+ console.warn(
1495
+ `[oagen] Merged "${jsonModel.name}" into "${baseModel.name}" (${jsonModel.fields.length} fields, superset)`
1496
+ );
1435
1497
  }
1436
- return normalizedModels;
1498
+ return normalizedModels.filter((m) => !toRemove.has(m.name));
1437
1499
  }
1438
1500
 
1439
1501
  // src/parser/collect-inline-enums.ts
@@ -1495,11 +1557,11 @@ async function parseSpec(specPath, options) {
1495
1557
  options?.operationIdTransform
1496
1558
  );
1497
1559
  const responseNormalizedModels = mergeInlineResponseModels(models, inlineModels);
1498
- const jsonCollapsedModels = collapseJsonSuffixModels(responseNormalizedModels, services);
1499
1560
  const fieldInlineModels = extractInlineModelsFromSchemas(
1500
1561
  spec.components?.schemas
1501
1562
  );
1502
- const finalModels = mergeFieldInlineModels(jsonCollapsedModels, fieldInlineModels);
1563
+ const fieldMergedModels = mergeFieldInlineModels(responseNormalizedModels, fieldInlineModels);
1564
+ const finalModels = collapseJsonSuffixModels(fieldMergedModels, services);
1503
1565
  collectInlineEnumsFromModels(finalModels, enums);
1504
1566
  const auth = extractAuthSchemes(spec.components?.securitySchemes);
1505
1567
  const serverEntries = (spec.servers ?? []).map((s) => ({ url: s.url ?? "", description: s.description })).filter((s) => s.url);
package/dist/smoke.js CHANGED
@@ -253,17 +253,26 @@ function buildInlineModel(name, schema) {
253
253
 
254
254
  // src/parser/schemas.ts
255
255
  function extractSchemas(schemas) {
256
- const models = [];
257
256
  const enums = [];
258
- if (!schemas) return { models, enums };
257
+ if (!schemas) return { models: [], enums };
258
+ const modelsByCleanName = /* @__PURE__ */ new Map();
259
259
  for (const [name, schema] of Object.entries(schemas)) {
260
260
  const pascalName = cleanSchemaName(toPascalCase(name));
261
261
  if (schema.enum) {
262
262
  enums.push(extractEnum(pascalName, schema));
263
263
  } else {
264
- models.push(extractModel(pascalName, schema, schemas));
264
+ const model = extractModel(pascalName, schema, schemas);
265
+ const existing = modelsByCleanName.get(pascalName);
266
+ if (existing) {
267
+ if (model.fields.length > existing.fields.length) {
268
+ modelsByCleanName.set(pascalName, model);
269
+ }
270
+ } else {
271
+ modelsByCleanName.set(pascalName, model);
272
+ }
265
273
  }
266
274
  }
275
+ const models = [...modelsByCleanName.values()];
267
276
  const modelNames = new Set(models.map((m) => m.name));
268
277
  const inlineQueue = [...models];
269
278
  while (inlineQueue.length > 0) {
@@ -693,6 +702,18 @@ function classifyAndExtractResponse(schema, contextName) {
693
702
  }
694
703
  return extractDirectResource(schema, contextName);
695
704
  }
705
+ var KNOWN_DATA_PATHS = /* @__PURE__ */ new Set(["data", "items", "results", "records", "entries", "values", "nodes", "edges"]);
706
+ var PAGINATION_METADATA_PATTERNS = [
707
+ "metadata",
708
+ "pagination",
709
+ "cursor",
710
+ "has_more",
711
+ "page_info",
712
+ "total",
713
+ "next_page",
714
+ "previous_page",
715
+ "offset"
716
+ ];
696
717
  function detectListEnvelope(schema) {
697
718
  const propSources = [];
698
719
  if (schema.allOf) {
@@ -709,17 +730,26 @@ function detectListEnvelope(schema) {
709
730
  Object.assign(mergedProps, source);
710
731
  }
711
732
  const arrayProps = [];
712
- let nonArrayCount = 0;
733
+ const nonArrayKeys = [];
713
734
  for (const [key, propSchema] of Object.entries(mergedProps)) {
714
735
  if (!propSchema) continue;
715
736
  if (propSchema.type === "array") {
716
737
  arrayProps.push(key);
717
738
  } else {
718
- nonArrayCount++;
739
+ nonArrayKeys.push(key);
719
740
  }
720
741
  }
721
- if (arrayProps.length === 1 && nonArrayCount >= 1) {
722
- return { isEnvelope: true, dataPath: arrayProps[0] };
742
+ if (arrayProps.length === 1 && nonArrayKeys.length >= 1) {
743
+ const arrayPropName = arrayProps[0];
744
+ if (KNOWN_DATA_PATHS.has(arrayPropName)) {
745
+ return { isEnvelope: true, dataPath: arrayPropName };
746
+ }
747
+ const hasPaginationCompanion = nonArrayKeys.some(
748
+ (key) => PAGINATION_METADATA_PATTERNS.some((pattern) => key.toLowerCase().includes(pattern))
749
+ );
750
+ if (hasPaginationCompanion) {
751
+ return { isEnvelope: true, dataPath: arrayPropName };
752
+ }
723
753
  }
724
754
  return { isEnvelope: false, dataPath: null };
725
755
  }
@@ -1356,20 +1386,24 @@ function keepLargestByName(models) {
1356
1386
  }
1357
1387
  return [...byName.values()];
1358
1388
  }
1359
- function rewriteModelRefsInServices(services, oldName, newName) {
1389
+ function rewriteModelRef(ref, oldName, newName) {
1390
+ walkTypeRef(ref, {
1391
+ model: (r) => {
1392
+ if (r.name === oldName) r.name = newName;
1393
+ }
1394
+ });
1395
+ }
1396
+ function rewriteModelRefs(models, services, oldName, newName) {
1397
+ for (const model of models) {
1398
+ for (const field of model.fields) {
1399
+ rewriteModelRef(field.type, oldName, newName);
1400
+ }
1401
+ }
1360
1402
  for (const service of services) {
1361
1403
  for (const op of service.operations) {
1362
- walkTypeRef(op.response, {
1363
- model: (r) => {
1364
- if (r.name === oldName) r.name = newName;
1365
- }
1366
- });
1404
+ rewriteModelRef(op.response, oldName, newName);
1367
1405
  if (op.requestBody) {
1368
- walkTypeRef(op.requestBody, {
1369
- model: (r) => {
1370
- if (r.name === oldName) r.name = newName;
1371
- }
1372
- });
1406
+ rewriteModelRef(op.requestBody, oldName, newName);
1373
1407
  }
1374
1408
  }
1375
1409
  }
@@ -1418,9 +1452,30 @@ function mergeFieldInlineModels(existingModels, fieldInlineModels) {
1418
1452
  });
1419
1453
  return [...existingModels, ...keepLargestByName(deduplicatedFieldModels)];
1420
1454
  }
1455
+ function hasModelConstDiscriminant(model) {
1456
+ return model.fields.some(
1457
+ (f) => (f.name === "object" || f.name === "type") && f.type.kind === "literal" && typeof f.type.value === "string"
1458
+ );
1459
+ }
1460
+ function isModelReferencedByOthers(name, models, excludeNames) {
1461
+ for (const model of models) {
1462
+ if (excludeNames.has(model.name)) continue;
1463
+ for (const field of model.fields) {
1464
+ let found = false;
1465
+ walkTypeRef(field.type, {
1466
+ model: (r) => {
1467
+ if (r.name === name) found = true;
1468
+ }
1469
+ });
1470
+ if (found) return true;
1471
+ }
1472
+ }
1473
+ return false;
1474
+ }
1421
1475
  function collapseJsonSuffixModels(models, services) {
1422
1476
  const normalizedModels = [...models];
1423
1477
  const byName = new Map(normalizedModels.map((m) => [m.name, m]));
1478
+ const mergeCandidates = [];
1424
1479
  for (const model of normalizedModels) {
1425
1480
  if (!model.name.endsWith("Json")) continue;
1426
1481
  const baseName = model.name.slice(0, -4);
@@ -1428,13 +1483,20 @@ function collapseJsonSuffixModels(models, services) {
1428
1483
  if (!baseModel || model.fields.length <= baseModel.fields.length) continue;
1429
1484
  const isSuperset = baseModel.fields.every((f) => model.fields.some((mf) => mf.name === f.name));
1430
1485
  if (!isSuperset) continue;
1431
- baseModel.fields = model.fields;
1432
- const jsonIdx = normalizedModels.indexOf(model);
1433
- if (jsonIdx !== -1) normalizedModels.splice(jsonIdx, 1);
1434
- rewriteModelRefsInServices(services, model.name, baseName);
1435
- console.warn(`[oagen] Merged "${model.name}" into "${baseName}" (${model.fields.length} fields, superset)`);
1486
+ if (hasModelConstDiscriminant(model) && hasModelConstDiscriminant(baseModel)) continue;
1487
+ if (isModelReferencedByOthers(model.name, normalizedModels, /* @__PURE__ */ new Set([model.name, baseName]))) continue;
1488
+ mergeCandidates.push({ jsonModel: model, baseModel });
1489
+ }
1490
+ const toRemove = /* @__PURE__ */ new Set();
1491
+ for (const { jsonModel, baseModel } of mergeCandidates) {
1492
+ baseModel.fields = jsonModel.fields;
1493
+ toRemove.add(jsonModel.name);
1494
+ rewriteModelRefs(normalizedModels, services, jsonModel.name, baseModel.name);
1495
+ console.warn(
1496
+ `[oagen] Merged "${jsonModel.name}" into "${baseModel.name}" (${jsonModel.fields.length} fields, superset)`
1497
+ );
1436
1498
  }
1437
- return normalizedModels;
1499
+ return normalizedModels.filter((m) => !toRemove.has(m.name));
1438
1500
  }
1439
1501
 
1440
1502
  // src/parser/collect-inline-enums.ts
@@ -1496,11 +1558,11 @@ async function parseSpec(specPath, options) {
1496
1558
  options?.operationIdTransform
1497
1559
  );
1498
1560
  const responseNormalizedModels = mergeInlineResponseModels(models, inlineModels);
1499
- const jsonCollapsedModels = collapseJsonSuffixModels(responseNormalizedModels, services);
1500
1561
  const fieldInlineModels = extractInlineModelsFromSchemas(
1501
1562
  spec.components?.schemas
1502
1563
  );
1503
- const finalModels = mergeFieldInlineModels(jsonCollapsedModels, fieldInlineModels);
1564
+ const fieldMergedModels = mergeFieldInlineModels(responseNormalizedModels, fieldInlineModels);
1565
+ const finalModels = collapseJsonSuffixModels(fieldMergedModels, services);
1504
1566
  collectInlineEnumsFromModels(finalModels, enums);
1505
1567
  const auth = extractAuthSchemes(spec.components?.securitySchemes);
1506
1568
  const serverEntries = (spec.servers ?? []).map((s) => ({ url: s.url ?? "", description: s.description })).filter((s) => s.url);
@@ -2,7 +2,7 @@ import {
2
2
  diffSpecs,
3
3
  generate,
4
4
  getEmitter
5
- } from "../chunk-P5XUDUGY.js";
5
+ } from "../chunk-VUSRNIG2.js";
6
6
  import {
7
7
  buildOverlayLookup,
8
8
  diffSurfaces,
@@ -47,7 +47,7 @@ When changing pipeline behavior:
47
47
  - `src/parser/`: OpenAPI to IR (refs, schemas, operations, pagination, inline models)
48
48
  - `src/engine/`: orchestration, writer, merger, registry, shared engine types
49
49
  - Language-specific emitters live in the separate `oagen-emitters` project (import from `@workos/oagen`)
50
- - `src/differ/`: incremental diff engine
50
+ - `src/differ/`: spec diffing and breaking change classification
51
51
  - `src/compat/`: compat verification, overlay, staleness detection, extractors
52
52
  - `src/cli/`: CLI entry points and config loader
53
53
  - `src/utils/`: naming utilities (snake_case, camelCase, PascalCase conversions)
@@ -53,11 +53,11 @@ When regenerating an SDK for a language that already has a published SDK, verify
53
53
  1. Extract the live SDK's API surface (`oagen extract --sdk-path <path> --lang <language> --output <output>/sdk-{language}-surface.json`)
54
54
  2. Generate with the overlay (`--api-surface <output>/sdk-{language}-surface.json`) so the emitter preserves existing names
55
55
  3. Verify the generated output against the baseline (`oagen verify --api-surface <output>/sdk-{language}-surface.json --lang <language> --output <output>`)
56
- 4. If violations exist, fix the emitter and regenerate — loop mode (`--loop`) automates this cycle
56
+ 4. If violations exist, fix the emitter and regenerate — `--max-retries` on `oagen verify` automates the overlay patching cycle
57
57
 
58
58
  The differ and overlay delegate all language-specific logic to the extractor's `hints: LanguageHints` object. Every extractor must provide hints — use `resolveHints({...})` to override only what differs from Node defaults. See `docs/architecture/extractor-contract.md` for the full hints reference.
59
59
 
60
- The `--api-surface` flag is supported by both `oagen generate` and `oagen diff` (for incremental generation with compat overlay).
60
+ The `--api-surface` flag is supported by `oagen generate`.
61
61
 
62
62
  Run `/generate-extractor <language>` to scaffold the extractor, then `/verify-compat <language>` for the full guided workflow.
63
63
 
@@ -21,7 +21,7 @@ Top-level entrypoints (`src/index.ts`, `src/errors.ts`) are exempt from these di
21
21
  | `src/ir/` | (nothing in `src/`) | everything |
22
22
  | `src/utils/` | `src/ir/`, `src/errors.ts` | parser, engine, differ, compat... |
23
23
  | `src/parser/` | `src/ir/`, `src/utils/`, `src/errors.ts` | engine, differ, compat, verify, cli |
24
- | `src/engine/` | `src/ir/`, `src/utils/`, `src/errors.ts`, `src/differ/` | parser, verify, cli |
24
+ | `src/engine/` | `src/ir/`, `src/utils/`, `src/errors.ts` | parser, differ, verify, cli |
25
25
  | `src/differ/` | `src/ir/`, `src/utils/`, `src/errors.ts` | parser, compat, verify, cli |
26
26
  | `src/compat/` | `src/ir/`, `src/utils/`, `src/errors.ts`, `src/differ/` | parser, verify, cli |
27
27
  | `src/verify/` | `src/ir/`, `src/utils/`, `src/errors.ts`, `src/engine/`, `src/compat/` | parser, cli |
@@ -59,10 +59,6 @@ Two type-only exceptions are currently allowed:
59
59
  - `differ -> engine/types` for shared generation types such as `EmitterContext` and `GeneratedFile`
60
60
  - `engine -> compat/types` because overlay-aware generation threads compat types through `EmitterContext` and generation options without depending on compat implementation modules
61
61
 
62
- One runtime exception is currently allowed:
63
-
64
- - `engine -> differ` for incremental generation, which depends on spec diffing and file mapping
65
-
66
62
  If you add a new exception, keep it narrow, make it type-only when possible, and document the reason in both code and docs.
67
63
 
68
64
  ## Enforcement
@@ -18,7 +18,7 @@ interface Emitter {
18
18
  generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
19
19
  generateErrors(ctx: EmitterContext): GeneratedFile[];
20
20
  generateConfig(ctx: EmitterContext): GeneratedFile[];
21
- generateTypeSignatures(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
21
+ generateTypeSignatures?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
22
22
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
23
23
  generateManifest?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[];
24
24
  fileHeader(): string;
@@ -31,8 +31,9 @@ interface Emitter {
31
31
  interface GeneratedFile {
32
32
  path: string; // Relative path within output directory
33
33
  content: string; // File content (header prepended by orchestrator)
34
- header?: string; // Optional override of the default file header
35
34
  skipIfExists?: boolean; // Don't overwrite if file already exists on disk (defaults to false)
35
+ headerPlacement?: "prepend" | "skip"; // How to handle the file header (default: 'prepend')
36
+ integrateTarget?: boolean; // When false, exclude from --target integration (default: true)
36
37
  }
37
38
  ```
38
39
 
@@ -53,7 +54,7 @@ interface EmitterContext {
53
54
 
54
55
  ## Overlay Integration
55
56
 
56
- When a user passes `--api-surface` to `oagen generate` or `oagen diff`, the engine builds an `OverlayLookup` and passes it to emitters via `ctx.overlayLookup`. Emitters should check the overlay before generating default names to preserve backwards compatibility with an existing SDK.
57
+ When a user passes `--api-surface` to `oagen generate`, the engine builds an `OverlayLookup` and passes it to emitters via `ctx.overlayLookup`. Emitters should check the overlay before generating default names to preserve backwards compatibility with an existing SDK.
57
58
 
58
59
  **When it's present:** The user has an existing live SDK and wants the generated output to preserve its public API (method names, type names, exports).
59
60
 
@@ -75,13 +76,15 @@ When a user passes `--api-surface` to `oagen generate` or `oagen diff`, the engi
75
76
 
76
77
  **`OverlayLookup` fields:**
77
78
 
78
- | Field | Type | Purpose |
79
- | ------------------- | ---------------------------- | ------------------------------------------------ |
80
- | `methodByOperation` | `Map<string, MethodOverlay>` | HTTP key → existing method info (name, params) |
81
- | `httpKeyByMethod` | `Map<string, string>` | Reverse map: "Class.method" → HTTP key |
82
- | `interfaceByName` | `Map<string, string>` | IR interface name → existing interface name |
83
- | `typeAliasByName` | `Map<string, string>` | IR type alias name → existing type alias name |
84
- | `requiredExports` | `Map<string, Set<string>>` | Barrel file path → symbols that must be exported |
79
+ | Field | Type | Purpose |
80
+ | ------------------- | ---------------------------- | ----------------------------------------------------------------------- |
81
+ | `methodByOperation` | `Map<string, MethodOverlay>` | HTTP key → existing method info (name, params) |
82
+ | `httpKeyByMethod` | `Map<string, string>` | Reverse map: "Class.method" → HTTP key |
83
+ | `interfaceByName` | `Map<string, string>` | IR interface name → existing interface name |
84
+ | `typeAliasByName` | `Map<string, string>` | IR type alias name → existing type alias name |
85
+ | `requiredExports` | `Map<string, Set<string>>` | Barrel file path → symbols that must be exported |
86
+ | `modelNameByIR` | `Map<string, string>` | IR model name → SDK interface name (auto-inferred from field structure) |
87
+ | `fileBySymbol` | `Map<string, string>` | IR symbol name → relative file path in the live SDK |
85
88
 
86
89
  The `httpKeyByMethod` reverse map is only populated when a manifest (`smoke-manifest.json`) is available. Without it, method-level violations cannot be auto-patched in the self-correcting loop. Emitters that support compat verification should implement `generateManifest`.
87
90
 
@@ -35,16 +35,16 @@ interface ApiSurface {
35
35
 
36
36
  Each sub-type captures a different kind of public symbol:
37
37
 
38
- | Type | Fields | What it captures |
39
- | -------------- | ---------------------------------------------------- | ------------------------------------------------ |
40
- | `ApiClass` | `name`, `methods`, `properties`, `constructorParams` | Public classes with their methods and properties |
41
- | `ApiMethod` | `name`, `params`, `returnType`, `async` | Method signatures |
42
- | `ApiParam` | `name`, `type`, `optional` | Method and constructor parameters |
43
- | `ApiProperty` | `name`, `type`, `readonly` | Class properties |
44
- | `ApiInterface` | `name`, `fields`, `extends` | Interface declarations |
45
- | `ApiField` | `name`, `type`, `optional` | Interface fields |
46
- | `ApiTypeAlias` | `name`, `value` | Type alias declarations |
47
- | `ApiEnum` | `name`, `members` | Enum declarations with member values |
38
+ | Type | Fields | What it captures |
39
+ | -------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
40
+ | `ApiClass` | `name`, `sourceFile?`, `methods`, `properties`, `constructorParams` | Public classes with their methods and properties |
41
+ | `ApiMethod` | `name`, `params`, `returnType`, `async` | Method signatures (methods is `Record<string, ApiMethod[]>` to support overloads) |
42
+ | `ApiParam` | `name`, `type`, `optional` | Method and constructor parameters |
43
+ | `ApiProperty` | `name`, `type`, `readonly` | Class properties |
44
+ | `ApiInterface` | `name`, `sourceFile?`, `fields`, `extends` | Interface declarations |
45
+ | `ApiField` | `name`, `type`, `optional` | Interface fields |
46
+ | `ApiTypeAlias` | `name`, `sourceFile?`, `value` | Type alias declarations |
47
+ | `ApiEnum` | `name`, `sourceFile?`, `members` | Enum declarations with member values |
48
48
 
49
49
  The `exports` field maps file paths to their exported symbol names, capturing the barrel export structure.
50
50
 
@@ -62,18 +62,23 @@ The `exports` field maps file paths to their exported symbol names, capturing th
62
62
 
63
63
  Every extractor must provide a `hints: LanguageHints` object that tells the differ and overlay how to interpret language-specific type strings. The `LanguageHints` interface includes:
64
64
 
65
- | Method/Property | Purpose | Node example | Go example |
66
- | ----------------------------------- | --------------------------------------------- | ------------------------------------------------ | ------------------------------------ |
67
- | `stripNullable(type)` | Strip nullable wrapper | `"string \| null"` → `"string"` | `"*Organization"` → `"Organization"` |
68
- | `isNullableOnlyDifference(a, b)` | True if a and b differ only by nullability | `"string"` vs `"string \| null"` → true | |
69
- | `isUnionReorder(a, b)` | True if same union members in different order | `"a" \| "b"` vs `"b" \| "a"` → true | Always false (Go has no unions) |
70
- | `isGenericTypeParam(type)` | True if type is an unresolvable generic param | `"T"`, `"TCustomAttributes"` → true | |
71
- | `isExtractionArtifact(type)` | True if type is an extraction artifact | `"any"` → true | `"interface{}"` → true |
72
- | `tolerateCategoryMismatch` | Allow type alias ↔ interface/class mismatch | `true` (TS allows both forms) | `false` |
73
- | `extractReturnTypeName(returnType)` | Extract innermost type from return type | `"Promise<Organization>"` → `"Organization"` | |
74
- | `extractParamTypeName(paramType)` | Extract type from param (null for primitives) | `"string"` → null | |
75
- | `propertyMatchesClass(prop, class)` | True if property maps to class name | camelCase: `"organizations"` → `"Organizations"` | |
76
- | `derivedModelNames(modelName)` | Additional names a model produces | `["FooResponse", "SerializedFoo"]` | `["FooResponse"]` |
65
+ | Method/Property | Purpose | Node example | Go example |
66
+ | ----------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------ |
67
+ | `stripNullable(type)` | Strip nullable wrapper | `"string \| null"` → `"string"` | `"*Organization"` → `"Organization"` |
68
+ | `isNullableOnlyDifference(a, b)` | True if a and b differ only by nullability | `"string"` vs `"string \| null"` → true | |
69
+ | `isUnionReorder(a, b)` | True if same union members in different order | `"a" \| "b"` vs `"b" \| "a"` → true | Always false (Go has no unions) |
70
+ | `isGenericTypeParam(type)` | True if type is an unresolvable generic param | `"T"`, `"TCustomAttributes"` → true | |
71
+ | `isExtractionArtifact(type)` | True if type is an extraction artifact | `"any"` → true | `"interface{}"` → true |
72
+ | `tolerateCategoryMismatch` | Allow type alias ↔ interface/class mismatch | `true` (TS allows both forms) | `false` |
73
+ | `extractReturnTypeName(returnType)` | Extract innermost type from return type | `"Promise<Organization>"` → `"Organization"` | |
74
+ | `extractParamTypeName(paramType)` | Extract type from param (null for primitives) | `"string"` → null | |
75
+ | `propertyMatchesClass(prop, class)` | True if property maps to class name | camelCase: `"organizations"` → `"Organizations"` | |
76
+ | `derivedModelNames(modelName)` | Additional names a model produces | `["FooResponse", "SerializedFoo"]` | `["FooResponse"]` |
77
+ | `isTypeEquivalent?(a, b, surface)` | True if types are semantically equivalent | Named enum vs inline union of literals | |
78
+ | `isSignatureEquivalent?(a, b, s)` | True if signatures are equivalent despite structural differences | Unpacked params vs dict payload | |
79
+ | `modelBaseClasses?` | Base class names indicating a data model | `["BaseModel"]` (Python/Pydantic) | — |
80
+ | `exceptionBaseClasses?` | Base class names indicating an exception | `["Exception"]` | — |
81
+ | `listResourcePatterns?` | Type names for list/paginated wrappers | `["ListResource"]` | — |
77
82
 
78
83
  Use `resolveHints({...overrides})` to start from Node defaults and override only the methods that differ for your language:
79
84
 
@@ -225,3 +230,6 @@ Use `/generate-extractor <language>` for a guided workflow.
225
230
  | Rust | `rust.ts`, `rust-parser.ts`, `rust-surface.ts` |
226
231
  | PHP | `php.ts`, `php-parser.ts`, `php-surface.ts` |
227
232
  | Python | `python.ts`, `python-parser.ts`, `python-surface.ts` |
233
+ | Kotlin | `kotlin.ts`, `kotlin-parser.ts`, `kotlin-surface.ts` |
234
+ | .NET | `dotnet.ts`, `dotnet-parser.ts`, `dotnet-surface.ts` |
235
+ | Elixir | `elixir.ts`, `elixir-parser.ts`, `elixir-surface.ts` |
@@ -36,7 +36,7 @@ Every type in the IR is a `TypeRef`, discriminated on the `kind` field:
36
36
  | `primitive` | `PrimitiveType` | `{ kind: "primitive", type: "string" }` |
37
37
  | `array` | `ArrayType` | `{ kind: "array", items: { kind: "primitive", type: "string" } }` |
38
38
  | `model` | `ModelRef` | `{ kind: "model", name: "Organization" }` |
39
- | `enum` | `EnumRef` | `{ kind: "enum", name: "Status" }` |
39
+ | `enum` | `EnumRef` | `{ kind: "enum", name: "Status", values?: ["active", "inactive"] }` |
40
40
  | `union` | `UnionType` | `{ kind: "union", variants: [...], discriminator?: {...} }` |
41
41
  | `nullable` | `NullableType` | `{ kind: "nullable", inner: { kind: "primitive", type: "string" } }` |
42
42
  | `literal` | `LiteralType` | `{ kind: "literal", value: "active" }` |
@@ -196,6 +196,7 @@ interface Parameter {
196
196
  description?: string;
197
197
  deprecated?: boolean;
198
198
  default?: unknown;
199
+ example?: unknown;
199
200
  }
200
201
  ```
201
202
 
@@ -107,4 +107,4 @@ export default {
107
107
 
108
108
  ## Diff Engine (`src/differ/`)
109
109
 
110
- Compares two parsed specs (old vs new) and determines which files need regeneration. Classifies changes as additive, breaking, or mixed.
110
+ Compares two parsed specs (old vs new) and produces a `DiffReport` classifying changes as additive, breaking, or mixed. Used by the `oagen diff` CLI command for review before regenerating.
@@ -101,20 +101,11 @@ output of Scenario B's initial generation).
101
101
  Every spec update uses `--target` to merge into the live SDK and `--api-surface`
102
102
  to preserve backwards compatibility:
103
103
 
104
- 1. **Review changes:** `oagen diff --old v1.yml --new v2.yml --report`
104
+ 1. **Review changes:** `oagen diff --old v1.yml --new v2.yml`
105
105
  2. **Regenerate:** `oagen generate --spec v2.yml --lang {lang} --output ./sdk --target {sdk_path} --api-surface sdk-{lang}-surface.json`
106
106
  3. **Verify:** `oagen verify --spec v2.yml --lang {lang} --output ./sdk --api-surface sdk-{lang}-surface.json`
107
107
  4. **Ship** if verify exits 0
108
108
 
109
- **Alternative: incremental generation.** Instead of full `generate`, use
110
- `diff` in generation mode to only regenerate affected files. This also supports
111
- `--target` for live SDK integration:
112
-
113
- ```bash
114
- oagen diff --old v1.yml --new v2.yml --lang ruby --output ./sdk --target {sdk_path} --api-surface sdk-{lang}-surface.json
115
- oagen verify --spec v2.yml --lang ruby --output ./sdk --api-surface sdk-{lang}-surface.json
116
- ```
117
-
118
109
  **External consumers** configure emitters and extractors via `oagen.config.ts`
119
110
  instead of modifying CLI source — see the Configuration section in the README.
120
111
 
@@ -144,17 +135,27 @@ The verify pipeline returns structured result types that consumers can use progr
144
135
  ```typescript
145
136
  interface VerifyDiagnostics {
146
137
  compatCheck?: {
147
- totalBaselineSymbols;
148
- preservedSymbols;
149
- preservationScore;
150
- violationsByCategory;
151
- violationsBySeverity;
152
- additions;
153
- scopedToSpec;
154
- scopedSymbolCount?;
138
+ totalBaselineSymbols: number;
139
+ preservedSymbols: number;
140
+ preservationScore: number;
141
+ violationsByCategory: Record<string, number>;
142
+ violationsBySeverity: Record<string, number>;
143
+ additions: number;
144
+ scopedToSpec: boolean;
145
+ scopedSymbolCount?: number;
146
+ };
147
+ stalenessCheck?: { staleSymbolCount: number; staleSymbols: string[] };
148
+ smokeCheck?: {
149
+ passed: boolean;
150
+ findingsCount?: number;
151
+ compileErrors?: boolean;
152
+ };
153
+ retryLoop?: {
154
+ attempts: number;
155
+ converged: boolean;
156
+ finalScore: number;
157
+ patchedPerIteration: number[];
155
158
  };
156
- smokeCheck?: { passed; findingsCount?; compileErrors?; baselinePath };
157
- stalenessCheck?: { violations };
158
159
  }
159
160
 
160
161
  interface CompatCheckResult {