@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.
- package/README.md +1 -3
- package/dist/{chunk-P5XUDUGY.js → chunk-VUSRNIG2.js} +5 -92
- package/dist/cli/index.js +177 -466
- package/dist/compat/index.js +40 -2
- package/dist/index.d.ts +2 -25
- package/dist/index.js +122 -290
- package/dist/scripts/smoke/baseline.js +88 -26
- package/dist/smoke.js +88 -26
- package/dist/verify/index.js +1 -1
- package/docs/agents/architecture.md +1 -1
- package/docs/agents/emitters.md +2 -2
- package/docs/architecture/dependency-layers.md +1 -5
- package/docs/architecture/emitter-contract.md +13 -10
- package/docs/architecture/extractor-contract.md +30 -22
- package/docs/architecture/ir-types.md +2 -1
- package/docs/architecture/pipeline.md +1 -1
- package/docs/architecture/workflows.md +21 -20
- package/docs/cli.md +10 -14
- package/docs/contributor/public-api.md +2 -2
- package/package.json +1 -1
- package/skills/check-emitter-parity/SKILL.md +232 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
+
nonArrayKeys.push(key);
|
|
718
739
|
}
|
|
719
740
|
}
|
|
720
|
-
if (arrayProps.length === 1 &&
|
|
721
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
739
|
+
nonArrayKeys.push(key);
|
|
719
740
|
}
|
|
720
741
|
}
|
|
721
|
-
if (arrayProps.length === 1 &&
|
|
722
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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
|
|
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);
|
package/dist/verify/index.js
CHANGED
|
@@ -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/`:
|
|
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)
|
package/docs/agents/emitters.md
CHANGED
|
@@ -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 —
|
|
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
|
|
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
|
|
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
|
|
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
|
|
39
|
-
| -------------- |
|
|
40
|
-
| `ApiClass` | `name`, `methods`, `properties`, `constructorParams` | Public classes with their methods and properties
|
|
41
|
-
| `ApiMethod` | `name`, `params`, `returnType`, `async`
|
|
42
|
-
| `ApiParam` | `name`, `type`, `optional`
|
|
43
|
-
| `ApiProperty` | `name`, `type`, `readonly`
|
|
44
|
-
| `ApiInterface` | `name`, `fields`, `extends` | Interface declarations
|
|
45
|
-
| `ApiField` | `name`, `type`, `optional`
|
|
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
|
|
66
|
-
| ----------------------------------- |
|
|
67
|
-
| `stripNullable(type)` | Strip nullable wrapper
|
|
68
|
-
| `isNullableOnlyDifference(a, b)` | True if a and b differ only by nullability
|
|
69
|
-
| `isUnionReorder(a, b)` | True if same union members in different order
|
|
70
|
-
| `isGenericTypeParam(type)` | True if type is an unresolvable generic param
|
|
71
|
-
| `isExtractionArtifact(type)` | True if type is an extraction artifact
|
|
72
|
-
| `tolerateCategoryMismatch` | Allow type alias ↔ interface/class mismatch
|
|
73
|
-
| `extractReturnTypeName(returnType)` | Extract innermost type from return type
|
|
74
|
-
| `extractParamTypeName(paramType)` | Extract type from param (null for primitives)
|
|
75
|
-
| `propertyMatchesClass(prop, class)` | True if property maps to class name
|
|
76
|
-
| `derivedModelNames(modelName)` | Additional names a model produces
|
|
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
|
|
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
|
|
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 {
|