@workos/oagen 0.12.0 → 0.13.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.
@@ -2278,7 +2278,7 @@ function validateModelRefs(spec) {
2278
2278
  //#region src/parser/parse.ts
2279
2279
  async function parseSpec(specPath, options) {
2280
2280
  const { parsed } = await loadAndBundleSpec(specPath);
2281
- const spec = parsed;
2281
+ const spec = options?.transformSpec ? options.transformSpec(parsed) : parsed;
2282
2282
  const version = spec.openapi ?? "";
2283
2283
  if (!version.startsWith("3.")) throw new SpecParseError(`Unsupported OpenAPI version: ${version}. oagen requires OpenAPI 3.x`, `Update the spec to OpenAPI 3.0 or 3.1. If you are using Swagger 2.x, convert it first with \`npx swagger2openapi ${specPath}\`.`);
2284
2284
  const { models, enums } = extractSchemas(spec.components?.schemas, { schemaNameTransform: options?.schemaNameTransform });
@@ -2326,7 +2326,11 @@ function extractAuthSchemes(securitySchemes) {
2326
2326
  //#endregion
2327
2327
  //#region src/cli/parse.ts
2328
2328
  async function parseCommand(opts) {
2329
- const ir = await parseSpec(opts.spec);
2329
+ const ir = await parseSpec(opts.spec, {
2330
+ operationIdTransform: opts.operationIdTransform,
2331
+ schemaNameTransform: opts.schemaNameTransform,
2332
+ transformSpec: opts.transformSpec
2333
+ });
2330
2334
  console.log(JSON.stringify(ir, null, 2));
2331
2335
  }
2332
2336
  //#endregion
@@ -4289,7 +4293,7 @@ async function writeFiles(files, outputDir, options) {
4289
4293
  continue;
4290
4294
  }
4291
4295
  if (file.skipIfExists) {
4292
- if (header && !existingContent.startsWith(header)) await fs.writeFile(fullPath, header + "\n\n" + existingContent, "utf-8");
4296
+ if (header && !existingContent.startsWith(header) && !file.path.endsWith(".json")) await fs.writeFile(fullPath, header + "\n\n" + existingContent, "utf-8");
4293
4297
  result.skipped.push(file.path);
4294
4298
  continue;
4295
4299
  }
@@ -5907,7 +5911,8 @@ function expandDocUrls(spec, docUrl) {
5907
5911
  async function generateCommand(opts) {
5908
5912
  let ir = await parseSpec(opts.spec, {
5909
5913
  operationIdTransform: opts.operationIdTransform,
5910
- schemaNameTransform: opts.schemaNameTransform
5914
+ schemaNameTransform: opts.schemaNameTransform,
5915
+ transformSpec: opts.transformSpec
5911
5916
  });
5912
5917
  if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
5913
5918
  const emitter = getEmitter(opts.lang);
@@ -6293,7 +6298,8 @@ function summarize(changes) {
6293
6298
  async function diffCommand(opts) {
6294
6299
  const parseOptions = {
6295
6300
  operationIdTransform: opts.operationIdTransform,
6296
- schemaNameTransform: opts.schemaNameTransform
6301
+ schemaNameTransform: opts.schemaNameTransform,
6302
+ transformSpec: opts.transformSpec
6297
6303
  };
6298
6304
  let [oldSpec, newSpec] = await Promise.all([parseSpec(opts.old, parseOptions), parseSpec(opts.new, parseOptions)]);
6299
6305
  if (opts.docUrl) {
@@ -6668,7 +6674,10 @@ async function compatExtractCommand(opts) {
6668
6674
  if (opts.spec) {
6669
6675
  const specContent = readFileSync(opts.spec, "utf-8");
6670
6676
  snapshot.source.specSha = createHash("sha256").update(specContent).digest("hex");
6671
- enrichWithSpecContext(snapshot, await parseSpec(opts.spec, { schemaNameTransform: opts.schemaNameTransform }));
6677
+ enrichWithSpecContext(snapshot, await parseSpec(opts.spec, {
6678
+ schemaNameTransform: opts.schemaNameTransform,
6679
+ transformSpec: opts.transformSpec
6680
+ }));
6672
6681
  }
6673
6682
  const outputPath = resolve(opts.output, SNAPSHOT_FILENAME);
6674
6683
  writeFileSync(outputPath, JSON.stringify(snapshot, null, 2));
@@ -7211,12 +7220,95 @@ function diffSnapshots(baseline, candidate, policy) {
7211
7220
  if (isServiceWrapperConstructor(candSym, serviceAccessors)) continue;
7212
7221
  changes.push(classifyAddedSymbol(candSym));
7213
7222
  }
7223
+ detectForkedSchemas(changes, baseline, candidate);
7214
7224
  return {
7215
7225
  changes,
7216
7226
  summary: summarizeChanges(changes)
7217
7227
  };
7218
7228
  }
7219
7229
  /**
7230
+ * Detect "schema fork" antipattern and attach a remediation hint.
7231
+ *
7232
+ * Fires when:
7233
+ * 1. A return type or field type changed from `OldType` → `NewType`.
7234
+ * 2. `NewType` is newly added in the candidate snapshot.
7235
+ * 3. `NewType.fields ⊇ OldType.fields` (every field on the old type still
7236
+ * exists on the new one — the new schema is a strict superset, so the
7237
+ * same fields could have been added to the existing schema additively).
7238
+ *
7239
+ * Mutates `changes` in place: sets `remediation` on the matching change.
7240
+ * Leaves the original category and severity alone — this is just a hint.
7241
+ *
7242
+ * Identity match is by `displayName` of the type symbol, which corresponds
7243
+ * to the type name as it appears in `change.old.returnType` / `.type` and
7244
+ * `change.new.returnType` / `.type`.
7245
+ */
7246
+ function detectForkedSchemas(changes, baseline, candidate) {
7247
+ const baselineTypeFields = collectTypeFieldSets(baseline);
7248
+ const candidateTypeFields = collectTypeFieldSets(candidate);
7249
+ const baselineTypeNames = new Set(baselineTypeFields.keys());
7250
+ for (const change of changes) {
7251
+ if (change.remediation) continue;
7252
+ let oldType;
7253
+ let newType;
7254
+ if (change.category === "return_type_changed") {
7255
+ oldType = change.old.returnType;
7256
+ newType = change.new.returnType;
7257
+ } else if (change.category === "field_type_changed") {
7258
+ oldType = change.old.type;
7259
+ newType = change.new.type;
7260
+ } else continue;
7261
+ if (!oldType || !newType || oldType === newType) continue;
7262
+ const oldTypeBare = bareTypeName(oldType);
7263
+ const newTypeBare = bareTypeName(newType);
7264
+ if (baselineTypeNames.has(newTypeBare)) continue;
7265
+ const oldFields = baselineTypeFields.get(oldTypeBare);
7266
+ const newFields = candidateTypeFields.get(newTypeBare);
7267
+ if (!oldFields || !newFields) continue;
7268
+ if (oldFields.size === 0) continue;
7269
+ let isSuperset = true;
7270
+ for (const f of oldFields) if (!newFields.has(f)) {
7271
+ isSuperset = false;
7272
+ break;
7273
+ }
7274
+ if (!isSuperset) continue;
7275
+ change.remediation = `Schema "${newTypeBare}" looks like "${oldTypeBare}" with additional fields. Consider adding the new fields to "${oldTypeBare}" instead of forking a new schema — forking forces a breaking type-name change in typed SDKs, while extending the existing schema is additive.`;
7276
+ }
7277
+ }
7278
+ /**
7279
+ * Build a map from type fqName → set of (lowercased) child field names.
7280
+ * Used by `detectForkedSchemas` for superset comparison.
7281
+ */
7282
+ function collectTypeFieldSets(snapshot) {
7283
+ const result = /* @__PURE__ */ new Map();
7284
+ for (const sym of snapshot.symbols) {
7285
+ if (sym.kind !== "field" && sym.kind !== "property") continue;
7286
+ if (!sym.ownerFqName) continue;
7287
+ const localName = sym.fqName.includes(".") ? sym.fqName.slice(sym.fqName.lastIndexOf(".") + 1) : sym.fqName;
7288
+ let set = result.get(sym.ownerFqName);
7289
+ if (!set) {
7290
+ set = /* @__PURE__ */ new Set();
7291
+ result.set(sym.ownerFqName, set);
7292
+ }
7293
+ set.add(localName.toLowerCase());
7294
+ }
7295
+ return result;
7296
+ }
7297
+ /**
7298
+ * Strip array/nullable suffixes so we compare bare type names. Languages
7299
+ * encode these differently (`Foo[]`, `Foo | null`, `Foo?`, `List<Foo>`,
7300
+ * `Optional[Foo]`); this is a best-effort common-case stripper, not an
7301
+ * exhaustive parser.
7302
+ */
7303
+ function bareTypeName(t) {
7304
+ let s = t.trim();
7305
+ s = s.replace(/\s*\|\s*null$/, "").replace(/\?$/, "");
7306
+ s = s.replace(/\[\]$/, "");
7307
+ const generic = s.match(/^[A-Za-z_][A-Za-z0-9_.]*<\s*([A-Za-z_][A-Za-z0-9_.]*)\s*>$/);
7308
+ if (generic) s = generic[1];
7309
+ return s;
7310
+ }
7311
+ /**
7220
7312
  * Check if a symbol is a constructor belonging to a service wrapper class.
7221
7313
  *
7222
7314
  * Service wrapper constructors are internal plumbing (taking a client/config
@@ -7300,7 +7392,8 @@ function generateReport(diff, language) {
7300
7392
  provenance: c.provenance,
7301
7393
  old: c.old,
7302
7394
  new: c.new,
7303
- ...c.message ? { message: c.message } : {}
7395
+ ...c.message ? { message: c.message } : {},
7396
+ ...c.remediation ? { remediation: c.remediation } : {}
7304
7397
  }))
7305
7398
  };
7306
7399
  }
@@ -7321,6 +7414,7 @@ function formatHumanSummary(diff, opts) {
7321
7414
  lines.push(" Breaking:");
7322
7415
  for (const c of breaking) {
7323
7416
  lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
7417
+ if (c.remediation) lines.push(` hint: ${c.remediation}`);
7324
7418
  if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
7325
7419
  }
7326
7420
  }
@@ -7329,6 +7423,7 @@ function formatHumanSummary(diff, opts) {
7329
7423
  lines.push(" Soft-risk:");
7330
7424
  for (const c of softRisk) {
7331
7425
  lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
7426
+ if (c.remediation) lines.push(` hint: ${c.remediation}`);
7332
7427
  if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
7333
7428
  }
7334
7429
  }
@@ -7895,7 +7990,7 @@ function printCompatResult(result, baseline) {
7895
7990
  console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
7896
7991
  }
7897
7992
  async function verifyCommand(opts) {
7898
- const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform, compatConfig, compatReport, compatExplain } = opts;
7993
+ const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform, transformSpec, compatConfig, compatReport, compatExplain } = opts;
7899
7994
  const maxRetries = opts.maxRetries ?? 3;
7900
7995
  const diagData = {};
7901
7996
  const effectiveFailOn = opts.compatFailOn ?? compatConfig?.failOn ?? "breaking";
@@ -7911,7 +8006,8 @@ async function verifyCommand(opts) {
7911
8006
  let parsedSpec;
7912
8007
  if (effectiveScope === "spec-only" && spec) parsedSpec = await parseSpec(spec, {
7913
8008
  operationIdTransform,
7914
- schemaNameTransform
8009
+ schemaNameTransform,
8010
+ transformSpec
7915
8011
  });
7916
8012
  else if (effectiveScope === "spec-only") throw new CommandError("error: --scope spec-only requires --spec <path>", "", 1);
7917
8013
  const compatFlow = parsedSpec && maxRetries > 0 ? await runOverlayRetryLoop({
@@ -7978,10 +8074,12 @@ async function verifyCommand(opts) {
7978
8074
  console.log(separator);
7979
8075
  const stalenessResult = runStalenessCheck(baseline, await parseSpec(oldSpec, {
7980
8076
  operationIdTransform,
7981
- schemaNameTransform
8077
+ schemaNameTransform,
8078
+ transformSpec
7982
8079
  }), await parseSpec(spec, {
7983
8080
  operationIdTransform,
7984
- schemaNameTransform
8081
+ schemaNameTransform,
8082
+ transformSpec
7985
8083
  }), lang);
7986
8084
  if (stalenessResult.violations.length > 0) {
7987
8085
  console.log(`Found ${stalenessResult.violations.length} stale symbol(s):`);
@@ -8281,7 +8379,8 @@ async function initCommand(opts) {
8281
8379
  async function resolveCommand(opts) {
8282
8380
  let ir = await parseSpec(opts.spec, {
8283
8381
  operationIdTransform: opts.operationIdTransform,
8284
- schemaNameTransform: opts.schemaNameTransform
8382
+ schemaNameTransform: opts.schemaNameTransform,
8383
+ transformSpec: opts.transformSpec
8285
8384
  });
8286
8385
  if (opts.docUrl) ir = expandDocUrls(ir, opts.docUrl);
8287
8386
  const resolved = resolveOperations(ir, opts.operationHints, opts.mountRules);
@@ -8377,6 +8476,7 @@ const explicitConfigPath = configArgIdx !== -1 ? process.argv[configArgIdx + 1]
8377
8476
  let configSmokeRunners;
8378
8477
  let configOperationIdTransform;
8379
8478
  let configSchemaNameTransform;
8479
+ let configTransformSpec;
8380
8480
  let configDocUrl;
8381
8481
  let configOperationHints;
8382
8482
  let configMountRules;
@@ -8389,6 +8489,7 @@ try {
8389
8489
  configSmokeRunners = config.smokeRunners;
8390
8490
  configOperationIdTransform = config.operationIdTransform;
8391
8491
  configSchemaNameTransform = config.schemaNameTransform;
8492
+ configTransformSpec = config.transformSpec;
8392
8493
  configDocUrl = config.docUrl;
8393
8494
  configOperationHints = config.operationHints;
8394
8495
  configMountRules = config.mountRules;
@@ -8405,7 +8506,12 @@ program.command("parse").description("Parse an OpenAPI spec and output IR as JSO
8405
8506
  console.error("error: --spec <path> or OPENAPI_SPEC_PATH env var is required");
8406
8507
  process.exit(1);
8407
8508
  }
8408
- return parseCommand(opts);
8509
+ return parseCommand({
8510
+ ...opts,
8511
+ operationIdTransform: configOperationIdTransform,
8512
+ schemaNameTransform: configSchemaNameTransform,
8513
+ transformSpec: configTransformSpec
8514
+ });
8409
8515
  });
8410
8516
  program.command("generate").description("Run a registered emitter against an OpenAPI spec").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Output directory").option("--target <dir>", "Target directory for live SDK integration (merged output)").option("--namespace <name>", "SDK namespace/package name").option("--dry-run", "Preview files without writing").option("--api-surface <path>", "Path to baseline API surface JSON for compat overlay").option("--no-compat-check", "Skip compat overlay even if --api-surface is provided").option("--no-prune", "Skip deletion of stale files recorded in the previous .oagen-manifest.json").action((opts) => {
8411
8517
  opts.spec ??= process.env.OPENAPI_SPEC_PATH;
@@ -8417,6 +8523,7 @@ program.command("generate").description("Run a registered emitter against an Ope
8417
8523
  ...opts,
8418
8524
  operationIdTransform: configOperationIdTransform,
8419
8525
  schemaNameTransform: configSchemaNameTransform,
8526
+ transformSpec: configTransformSpec,
8420
8527
  docUrl: configDocUrl,
8421
8528
  operationHints: configOperationHints,
8422
8529
  mountRules: configMountRules,
@@ -8428,6 +8535,7 @@ program.command("diff").description("Compare two OpenAPI specs and output a diff
8428
8535
  ...opts,
8429
8536
  operationIdTransform: configOperationIdTransform,
8430
8537
  schemaNameTransform: configSchemaNameTransform,
8538
+ transformSpec: configTransformSpec,
8431
8539
  docUrl: configDocUrl
8432
8540
  }).catch(handleError);
8433
8541
  });
@@ -8439,7 +8547,8 @@ program.command("compat-extract").description("Extract a compat snapshot from a
8439
8547
  opts.spec ??= process.env.OPENAPI_SPEC_PATH;
8440
8548
  compatExtractCommand({
8441
8549
  ...opts,
8442
- schemaNameTransform: configSchemaNameTransform
8550
+ schemaNameTransform: configSchemaNameTransform,
8551
+ transformSpec: configTransformSpec
8443
8552
  }).catch(handleError);
8444
8553
  });
8445
8554
  program.command("compat-diff").description("Diff two compat snapshot files and produce a classified change report").requiredOption("--baseline <path>", "Path to the baseline compat snapshot JSON").requiredOption("--candidate <path>", "Path to the candidate compat snapshot JSON").option("--output <path>", "Write machine-readable report to this path").option("--fail-on <level>", "Fail threshold: none, breaking, or soft-risk", "breaking").option("--explain", "Include provenance explanations in output").action((opts) => {
@@ -8456,6 +8565,7 @@ program.command("verify").description("Run smoke tests (and optional compat chec
8456
8565
  maxRetries: parseInt(opts.maxRetries, 10),
8457
8566
  operationIdTransform: configOperationIdTransform,
8458
8567
  schemaNameTransform: configSchemaNameTransform,
8568
+ transformSpec: configTransformSpec,
8459
8569
  compatConfig: configCompat,
8460
8570
  compatReport: opts.compatReport,
8461
8571
  compatFailOn: opts.compatFailOn,
@@ -8473,6 +8583,7 @@ program.command("resolve").description("Resolve operation names from spec and ou
8473
8583
  ...opts,
8474
8584
  operationIdTransform: configOperationIdTransform,
8475
8585
  schemaNameTransform: configSchemaNameTransform,
8586
+ transformSpec: configTransformSpec,
8476
8587
  docUrl: configDocUrl,
8477
8588
  operationHints: configOperationHints,
8478
8589
  mountRules: configMountRules