@workos/oagen 0.7.0 → 0.8.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.
Files changed (52) hide show
  1. package/dist/cli/index.mjs +1123 -436
  2. package/dist/cli/index.mjs.map +1 -1
  3. package/dist/compat/index.d.mts +144 -57
  4. package/dist/compat/index.d.mts.map +1 -1
  5. package/dist/compat/index.mjs +362 -41
  6. package/dist/compat/index.mjs.map +1 -1
  7. package/dist/{diff-BYadQQV7.mjs → diff-BaH9qa8k.mjs} +11 -2
  8. package/dist/{diff-BYadQQV7.mjs.map → diff-BaH9qa8k.mjs.map} +1 -1
  9. package/dist/differ-CTl98yoS.d.mts +116 -0
  10. package/dist/differ-CTl98yoS.d.mts.map +1 -0
  11. package/dist/index.d.mts +3 -1
  12. package/dist/index.d.mts.map +1 -1
  13. package/dist/index.mjs +50 -10
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/{differ-CG5ZErKl.mjs → overlay-TYxHOCow.mjs} +1112 -553
  16. package/dist/overlay-TYxHOCow.mjs.map +1 -0
  17. package/dist/scripts/smoke/baseline.mjs +49 -9
  18. package/dist/scripts/smoke/baseline.mjs.map +1 -1
  19. package/dist/smoke.d.mts +10 -0
  20. package/dist/smoke.d.mts.map +1 -1
  21. package/dist/smoke.mjs +49 -9
  22. package/dist/smoke.mjs.map +1 -1
  23. package/dist/tree-sitter-C53w_0WE.mjs.map +1 -1
  24. package/dist/{types-DJMbCtIO.d.mts → types-mTtkgy_h.d.mts} +180 -2
  25. package/dist/types-mTtkgy_h.d.mts.map +1 -0
  26. package/dist/verify/index.d.mts +4 -3
  27. package/dist/verify/index.d.mts.map +1 -1
  28. package/dist/verify/index.mjs +12 -10
  29. package/dist/verify/index.mjs.map +1 -1
  30. package/docs/architecture/emitter-contract.md +2 -2
  31. package/docs/architecture/extractor-contract.md +6 -5
  32. package/docs/architecture/pipeline.md +4 -3
  33. package/docs/architecture/workflows.md +5 -3
  34. package/docs/cli.md +148 -6
  35. package/docs/contributor/public-api.md +1 -1
  36. package/docs/core/compatibility-ir.md +200 -0
  37. package/docs/core/compatibility-policy.md +170 -0
  38. package/docs/core/compatibility.md +83 -0
  39. package/docs/core/index.md +5 -0
  40. package/docs/core/manifest-schema.md +76 -0
  41. package/docs/core/quickstart.md +9 -2
  42. package/docs/core/verify-compat.md +235 -0
  43. package/package.json +1 -1
  44. package/skills/generate-emitter/SKILL.md +12 -7
  45. package/skills/generate-emitter/references/project-scaffold.md +27 -6
  46. package/skills/generate-extractor/SKILL.md +14 -8
  47. package/skills/generate-sdk/SKILL.md +2 -2
  48. package/skills/generate-smoke-test/SKILL.md +9 -5
  49. package/skills/review-operations/SKILL.md +2 -2
  50. package/skills/verify-compat/SKILL.md +1 -1
  51. package/dist/differ-CG5ZErKl.mjs.map +0 -1
  52. package/dist/types-DJMbCtIO.d.mts.map +0 -1
@@ -11,6 +11,7 @@ import Parser from "tree-sitter";
11
11
  import { execFile, execFileSync } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
14
15
  //#region src/ir/sdk-behavior.ts
15
16
  /**
16
17
  * Canonical SDK behavior defaults. These match the PHP emitter's current
@@ -822,6 +823,7 @@ function extractModel(name, schema, schemas) {
822
823
  function extractAllOfModel(name, schema, schemas) {
823
824
  const fields = [];
824
825
  const requiredSet = /* @__PURE__ */ new Set();
826
+ let resultDiscriminator;
825
827
  for (const subSchema of schema.allOf ?? []) {
826
828
  let resolved = subSchema;
827
829
  if (subSchema.$ref && schemas) {
@@ -840,14 +842,18 @@ function extractAllOfModel(name, schema, schemas) {
840
842
  }
841
843
  } else if (resolved.oneOf || resolved.anyOf) {
842
844
  const variants = resolved.oneOf ?? resolved.anyOf ?? [];
843
- const emptyRequired = /* @__PURE__ */ new Set();
844
- const seenFieldNames = new Set(fields.map((f) => f.name));
845
- for (const variant of variants) {
846
- if (!variant.properties) continue;
847
- for (const [fieldName, fieldSchema] of Object.entries(variant.properties)) {
848
- if (!fieldSchema || seenFieldNames.has(fieldName)) continue;
849
- seenFieldNames.add(fieldName);
850
- fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, emptyRequired));
845
+ const discriminatorInfo = detectAllOfVariantDiscriminator(variants, name);
846
+ if (discriminatorInfo) resultDiscriminator = discriminatorInfo;
847
+ else {
848
+ const emptyRequired = /* @__PURE__ */ new Set();
849
+ const seenFieldNames = new Set(fields.map((f) => f.name));
850
+ for (const variant of variants) {
851
+ if (!variant.properties) continue;
852
+ for (const [fieldName, fieldSchema] of Object.entries(variant.properties)) {
853
+ if (!fieldSchema || seenFieldNames.has(fieldName)) continue;
854
+ seenFieldNames.add(fieldName);
855
+ fields.push(buildFieldFromSchema(fieldName, fieldSchema, name, emptyRequired));
856
+ }
851
857
  }
852
858
  }
853
859
  } else {
@@ -866,7 +872,42 @@ function extractAllOfModel(name, schema, schemas) {
866
872
  return {
867
873
  name,
868
874
  description: schema.description,
869
- fields
875
+ fields,
876
+ ...resultDiscriminator ? { discriminator: resultDiscriminator } : {}
877
+ };
878
+ }
879
+ /**
880
+ * Detect an implicit discriminator on an allOf+oneOf where every variant is an
881
+ * object that pins the same property to a distinct const value. Unlike
882
+ * detectConstPropertyDiscriminator (which requires $ref or title for variant
883
+ * model names), this version derives model names from the const value itself
884
+ * using deriveDiscriminatedVariantName — matching how extractDiscriminatedAllOfVariantModels
885
+ * names those models.
886
+ *
887
+ * Returns null when no discriminator can be reliably detected.
888
+ */
889
+ function detectAllOfVariantDiscriminator(variants, fallbackName) {
890
+ if (variants.length < 2) return null;
891
+ const candidates = Object.keys(variants[0]?.properties ?? {});
892
+ let candidateProperty = null;
893
+ for (const propName of candidates) if (variants.every((v) => getConstPropertyValue(v, propName) !== null)) {
894
+ candidateProperty = propName;
895
+ break;
896
+ }
897
+ if (!candidateProperty) return null;
898
+ const mapping = {};
899
+ const seenModelNames = /* @__PURE__ */ new Set();
900
+ for (const v of variants) {
901
+ const value = getConstPropertyValue(v, candidateProperty);
902
+ if (value === null) return null;
903
+ const modelName = deriveDiscriminatedVariantName(v, fallbackName);
904
+ if (seenModelNames.has(modelName)) return null;
905
+ seenModelNames.add(modelName);
906
+ mapping[value] = modelName;
907
+ }
908
+ return {
909
+ property: candidateProperty,
910
+ mapping
870
911
  };
871
912
  }
872
913
  function extractDiscriminatedAllOfVariantModels(schema, fallbackName) {
@@ -4980,7 +5021,7 @@ async function formatTargetFiles(emitter, targetDir, filePaths) {
4980
5021
  * one-line notice is printed — no surprise deletions.
4981
5022
  */
4982
5023
  const MANIFEST_FILENAME = ".oagen-manifest.json";
4983
- const MANIFEST_VERSION = 1;
5024
+ const MANIFEST_VERSION = 2;
4984
5025
  /** Read `.oagen-manifest.json` from a directory. Returns null if absent or malformed. */
4985
5026
  async function readManifest(dir) {
4986
5027
  const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
@@ -5007,7 +5048,14 @@ async function writeManifest(dir, opts) {
5007
5048
  const manifest = {
5008
5049
  version: MANIFEST_VERSION,
5009
5050
  language: opts.language,
5051
+ ...opts.sdkName !== void 0 ? { sdkName: opts.sdkName } : {},
5010
5052
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5053
+ ...opts.specSha !== void 0 ? { specSha: opts.specSha } : {},
5054
+ ...opts.specPath !== void 0 ? { specPath: opts.specPath } : {},
5055
+ ...opts.emitterSha !== void 0 ? { emitterSha: opts.emitterSha } : {},
5056
+ ...opts.emitterVersion !== void 0 ? { emitterVersion: opts.emitterVersion } : {},
5057
+ ...opts.configSha !== void 0 ? { configSha: opts.configSha } : {},
5058
+ ...opts.compatSchemaVersion !== void 0 ? { compatSchemaVersion: opts.compatSchemaVersion } : {},
5011
5059
  files: [...new Set(opts.files)].sort()
5012
5060
  };
5013
5061
  const manifestPath = path$1.join(dir, MANIFEST_FILENAME);
@@ -5118,6 +5166,8 @@ async function generate(spec, emitter, options) {
5118
5166
  header,
5119
5167
  noPrune: options.noPrune
5120
5168
  });
5169
+ const allOutputFiles = [...writeResult.written, ...writeResult.merged];
5170
+ if (allOutputFiles.length > 0) await formatTargetFiles(emitter, options.outputDir, allOutputFiles);
5121
5171
  if (options.target) {
5122
5172
  const targetPrevManifest = targetManifestForCtx;
5123
5173
  const targetResult = await integrateGeneratedFiles({
@@ -5659,12 +5709,18 @@ function buildModelNameMap(surface, spec, lookup, hints, options) {
5659
5709
  }
5660
5710
  }
5661
5711
  }
5712
+ /** Categories that can be patched by the overlay retry loop. */
5713
+ const PATCHABLE_CATEGORIES = new Set(["symbol_removed", "symbol_renamed"]);
5714
+ /** Check if a classified change is patchable by the overlay loop. */
5715
+ function isPatchableChange(change) {
5716
+ return PATCHABLE_CATEGORIES.has(change.category);
5717
+ }
5662
5718
  /**
5663
- * Patch overlay with violations from a failed verification.
5719
+ * Patch overlay with classified changes from a failed verification.
5664
5720
  * Adds explicit name mappings for symbols that were generated with wrong names.
5665
5721
  * Returns a new OverlayLookup (immutable).
5666
5722
  */
5667
- function patchOverlay(overlay, violations, baseline) {
5723
+ function patchOverlay(overlay, changes, baseline) {
5668
5724
  const patched = {
5669
5725
  methodByOperation: new Map(overlay.methodByOperation),
5670
5726
  httpKeyByMethod: new Map(overlay.httpKeyByMethod),
@@ -5675,42 +5731,33 @@ function patchOverlay(overlay, violations, baseline) {
5675
5731
  fileBySymbol: new Map(overlay.fileBySymbol)
5676
5732
  };
5677
5733
  let warnedManifest = false;
5678
- for (const v of violations) {
5679
- if (v.category === "public-api") {
5680
- const parts = v.symbolPath.split(".");
5681
- if (parts.length === 2) {
5682
- const [className, methodName] = parts;
5683
- const baseClass = baseline.classes[className];
5684
- if (baseClass && baseClass.methods[methodName]?.length > 0) {
5685
- const httpKey = overlay.httpKeyByMethod.get(`${className}.${methodName}`);
5686
- if (!httpKey && overlay.httpKeyByMethod.size === 0 && !warnedManifest) {
5687
- console.warn("Warning: No smoke-manifest.json available. Method-level violations cannot be auto-patched. Implement generateManifest in your emitter for the self-correcting loop to resolve these.");
5688
- warnedManifest = true;
5689
- }
5690
- if (httpKey) {
5691
- const method = baseClass.methods[methodName][0];
5692
- patched.methodByOperation.set(httpKey, {
5693
- className,
5694
- methodName,
5695
- params: method.params,
5696
- returnType: method.returnType
5697
- });
5698
- }
5734
+ for (const c of changes) {
5735
+ if (!isPatchableChange(c)) continue;
5736
+ const parts = c.symbol.split(".");
5737
+ if (parts.length === 2) {
5738
+ const [className, methodName] = parts;
5739
+ const baseClass = baseline.classes[className];
5740
+ if (baseClass && baseClass.methods[methodName]?.length > 0) {
5741
+ const httpKey = overlay.httpKeyByMethod.get(`${className}.${methodName}`);
5742
+ if (!httpKey && overlay.httpKeyByMethod.size === 0 && !warnedManifest) {
5743
+ console.warn("Warning: No smoke-manifest.json available. Method-level violations cannot be auto-patched. Implement generateManifest in your emitter for the self-correcting loop to resolve these.");
5744
+ warnedManifest = true;
5745
+ }
5746
+ if (httpKey) {
5747
+ const method = baseClass.methods[methodName][0];
5748
+ patched.methodByOperation.set(httpKey, {
5749
+ className,
5750
+ methodName,
5751
+ params: method.params,
5752
+ returnType: method.returnType
5753
+ });
5699
5754
  }
5700
- if (baseline.interfaces[className]) patched.interfaceByName.set(className, className);
5701
- } else if (parts.length === 1) {
5702
- const name = parts[0];
5703
- if (baseline.interfaces[name]) patched.interfaceByName.set(name, name);
5704
- if (baseline.typeAliases[name]) patched.typeAliasByName.set(name, name);
5705
- }
5706
- }
5707
- if (v.category === "export-structure") {
5708
- const match = v.symbolPath.match(/^exports\[(.+?)\]\.(.+)$/);
5709
- if (match) {
5710
- const [, path, symbol] = match;
5711
- if (!patched.requiredExports.has(path)) patched.requiredExports.set(path, /* @__PURE__ */ new Set());
5712
- patched.requiredExports.get(path).add(symbol);
5713
5755
  }
5756
+ if (baseline.interfaces[className]) patched.interfaceByName.set(className, className);
5757
+ } else if (parts.length === 1) {
5758
+ const name = parts[0];
5759
+ if (baseline.interfaces[name]) patched.interfaceByName.set(name, name);
5760
+ if (baseline.typeAliases[name]) patched.typeAliasByName.set(name, name);
5714
5761
  }
5715
5762
  }
5716
5763
  return patched;
@@ -6247,6 +6294,327 @@ async function extractCommand(opts) {
6247
6294
  console.log(`Extracted ${symbolCount} symbols → ${opts.output}`);
6248
6295
  }
6249
6296
  //#endregion
6297
+ //#region src/cli/compat-extract.ts
6298
+ const SNAPSHOT_FILENAME = ".oagen-compat-snapshot.json";
6299
+ async function compatExtractCommand(opts) {
6300
+ const extractor = getExtractor(opts.lang);
6301
+ console.log(`Extracting ${opts.lang} compat snapshot from ${opts.sdkPath}...`);
6302
+ const snapshot = await extractor.extractSnapshot(opts.sdkPath);
6303
+ if (opts.spec) {
6304
+ const specContent = readFileSync(opts.spec, "utf-8");
6305
+ snapshot.source.specSha = createHash("sha256").update(specContent).digest("hex");
6306
+ enrichWithSpecContext(snapshot, await parseSpec(opts.spec));
6307
+ }
6308
+ const outputPath = resolve(opts.output, SNAPSHOT_FILENAME);
6309
+ writeFileSync(outputPath, JSON.stringify(snapshot, null, 2));
6310
+ console.log(`Extracted ${snapshot.symbols.length} symbols → ${outputPath}`);
6311
+ }
6312
+ /**
6313
+ * Enrich snapshot symbols with operationId and route from the parsed spec.
6314
+ *
6315
+ * Matches callable symbols to spec operations by comparing method names
6316
+ * derived from the spec against the symbol's fqName.
6317
+ */
6318
+ function enrichWithSpecContext(snapshot, spec) {
6319
+ const opLookup = /* @__PURE__ */ new Map();
6320
+ for (const service of spec.services) for (const op of service.operations) {
6321
+ const key = `${service.name}.${op.name}`;
6322
+ opLookup.set(key, {
6323
+ operationId: op.name,
6324
+ method: op.httpMethod,
6325
+ path: op.path
6326
+ });
6327
+ }
6328
+ for (const sym of snapshot.symbols) {
6329
+ if (sym.kind !== "callable") continue;
6330
+ const match = opLookup.get(sym.fqName);
6331
+ if (match) {
6332
+ sym.operationId = match.operationId;
6333
+ sym.route = {
6334
+ method: match.method,
6335
+ path: match.path
6336
+ };
6337
+ }
6338
+ }
6339
+ }
6340
+ //#endregion
6341
+ //#region src/compat/schema.ts
6342
+ /** Validate the basic structure of a parsed compat snapshot. */
6343
+ function validateSnapshot(data) {
6344
+ if (typeof data !== "object" || data === null) return false;
6345
+ const obj = data;
6346
+ return typeof obj.schemaVersion === "string" && typeof obj.source === "object" && obj.source !== null && typeof obj.policies === "object" && obj.policies !== null && Array.isArray(obj.symbols);
6347
+ }
6348
+ //#endregion
6349
+ //#region src/compat/config.ts
6350
+ const BREAKING_CATEGORIES = new Set([
6351
+ "symbol_removed",
6352
+ "symbol_renamed",
6353
+ "parameter_removed",
6354
+ "parameter_renamed",
6355
+ "parameter_requiredness_increased",
6356
+ "parameter_type_narrowed",
6357
+ "parameter_position_changed_order_sensitive",
6358
+ "constructor_position_changed_order_sensitive",
6359
+ "named_arg_name_removed",
6360
+ "keyword_name_removed",
6361
+ "overload_removed",
6362
+ "union_wrapper_migration_without_compat_alias",
6363
+ "field_type_changed",
6364
+ "return_type_changed",
6365
+ "enum_member_value_changed"
6366
+ ]);
6367
+ const SOFT_RISK_CATEGORIES = new Set([
6368
+ "parameter_added_non_terminal_optional",
6369
+ "constructor_reordered_named_friendly",
6370
+ "default_value_changed",
6371
+ "wrapper_stricter_than_previous_sdk_but_matches_spec",
6372
+ "doc_surface_drift"
6373
+ ]);
6374
+ /** Get the default severity for a change category. */
6375
+ function defaultSeverityForCategory(category) {
6376
+ if (BREAKING_CATEGORIES.has(category)) return "breaking";
6377
+ if (SOFT_RISK_CATEGORIES.has(category)) return "soft-risk";
6378
+ return "additive";
6379
+ }
6380
+ /** Check whether a severity meets or exceeds a fail threshold. */
6381
+ function severityMeetsThreshold(severity, threshold) {
6382
+ if (threshold === "none") return false;
6383
+ if (threshold === "soft-risk") return severity === "breaking" || severity === "soft-risk";
6384
+ return severity === "breaking";
6385
+ }
6386
+ //#endregion
6387
+ //#region src/compat/classify.ts
6388
+ /**
6389
+ * Classify changes between a baseline and candidate symbol.
6390
+ * Returns one or more classified changes for the diff.
6391
+ */
6392
+ function classifySymbolChanges(baseline, candidate, policy) {
6393
+ const changes = [];
6394
+ if (!candidate) {
6395
+ changes.push(makeChange({
6396
+ category: "symbol_removed",
6397
+ symbol: baseline.fqName,
6398
+ old: { symbol: baseline.fqName },
6399
+ new: { symbol: "(removed)" },
6400
+ message: `Symbol "${baseline.displayName}" was removed`,
6401
+ policy
6402
+ }));
6403
+ return changes;
6404
+ }
6405
+ if (baseline.fqName !== candidate.fqName && baseline.id === candidate.id) changes.push(makeChange({
6406
+ category: "symbol_renamed",
6407
+ symbol: baseline.fqName,
6408
+ old: { name: baseline.fqName },
6409
+ new: { name: candidate.fqName },
6410
+ message: `Symbol renamed from "${baseline.displayName}" to "${candidate.displayName}"`,
6411
+ policy
6412
+ }));
6413
+ if (baseline.parameters && candidate.parameters) changes.push(...classifyParameterChanges(baseline, candidate, policy));
6414
+ if (baseline.returns && candidate.returns && baseline.returns.name !== candidate.returns.name) changes.push(makeChange({
6415
+ category: "return_type_changed",
6416
+ symbol: baseline.fqName,
6417
+ old: { returnType: baseline.returns.name },
6418
+ new: { returnType: candidate.returns.name },
6419
+ message: `Return type changed for "${baseline.displayName}" from "${baseline.returns.name}" to "${candidate.returns.name}"`,
6420
+ policy
6421
+ }));
6422
+ if (baseline.typeRef && candidate.typeRef && baseline.typeRef.name !== candidate.typeRef.name) changes.push(makeChange({
6423
+ category: "field_type_changed",
6424
+ symbol: baseline.fqName,
6425
+ old: { type: baseline.typeRef.name },
6426
+ new: { type: candidate.typeRef.name },
6427
+ message: `Type changed for "${baseline.displayName}" from "${baseline.typeRef.name}" to "${candidate.typeRef.name}"`,
6428
+ policy
6429
+ }));
6430
+ if (baseline.kind === "enum_member" && candidate.kind === "enum_member" && baseline.value !== void 0 && candidate.value !== void 0 && baseline.value !== candidate.value) changes.push(makeChange({
6431
+ category: "enum_member_value_changed",
6432
+ symbol: baseline.fqName,
6433
+ old: { value: String(baseline.value) },
6434
+ new: { value: String(candidate.value) },
6435
+ message: `Enum value changed for "${baseline.displayName}" from "${baseline.value}" to "${candidate.value}"`,
6436
+ policy
6437
+ }));
6438
+ return changes;
6439
+ }
6440
+ /**
6441
+ * Classify parameter-level changes between two symbol versions.
6442
+ */
6443
+ function classifyParameterChanges(baseline, candidate, policy) {
6444
+ const changes = [];
6445
+ const baseParams = baseline.parameters ?? [];
6446
+ const candParams = candidate.parameters ?? [];
6447
+ const baseByName = new Map(baseParams.map((p) => [p.publicName, p]));
6448
+ const candByName = new Map(candParams.map((p) => [p.publicName, p]));
6449
+ const isConstructor = baseline.kind === "constructor";
6450
+ for (const baseParam of baseParams) {
6451
+ const candParam = candByName.get(baseParam.publicName);
6452
+ if (!candParam) {
6453
+ const positionalMatch = candParams[baseParam.position];
6454
+ if (positionalMatch && !baseByName.has(positionalMatch.publicName)) {
6455
+ const isBreakingRename = parameterNameIsPublicApi(baseParam, policy, isConstructor);
6456
+ changes.push(makeChange({
6457
+ category: "parameter_renamed",
6458
+ symbol: baseline.fqName,
6459
+ old: { parameter: baseParam.publicName },
6460
+ new: { parameter: positionalMatch.publicName },
6461
+ message: `Parameter "${baseParam.publicName}" renamed to "${positionalMatch.publicName}" on "${baseline.displayName}"`,
6462
+ policy,
6463
+ severityOverride: isBreakingRename ? void 0 : "soft-risk"
6464
+ }));
6465
+ } else changes.push(makeChange({
6466
+ category: "parameter_removed",
6467
+ symbol: baseline.fqName,
6468
+ old: { parameter: baseParam.publicName },
6469
+ new: { parameter: "(removed)" },
6470
+ message: `Parameter "${baseParam.publicName}" removed from "${baseline.displayName}"`,
6471
+ policy
6472
+ }));
6473
+ continue;
6474
+ }
6475
+ if (!baseParam.required && candParam.required) changes.push(makeChange({
6476
+ category: "parameter_requiredness_increased",
6477
+ symbol: baseline.fqName,
6478
+ old: {
6479
+ parameter: baseParam.publicName,
6480
+ required: "false"
6481
+ },
6482
+ new: {
6483
+ parameter: candParam.publicName,
6484
+ required: "true"
6485
+ },
6486
+ message: `Parameter "${baseParam.publicName}" became required on "${baseline.displayName}"`,
6487
+ policy
6488
+ }));
6489
+ if (baseParam.type.name !== candParam.type.name) changes.push(makeChange({
6490
+ category: "parameter_type_narrowed",
6491
+ symbol: baseline.fqName,
6492
+ old: {
6493
+ parameter: baseParam.publicName,
6494
+ type: baseParam.type.name
6495
+ },
6496
+ new: {
6497
+ parameter: candParam.publicName,
6498
+ type: candParam.type.name
6499
+ },
6500
+ message: `Parameter type changed for "${baseParam.publicName}" on "${baseline.displayName}"`,
6501
+ policy
6502
+ }));
6503
+ if (baseParam.position !== candParam.position) if (isConstructor ? policy.constructorOrderMatters : baseParam.sensitivity.order) {
6504
+ const category = isConstructor ? "constructor_position_changed_order_sensitive" : "parameter_position_changed_order_sensitive";
6505
+ changes.push(makeChange({
6506
+ category,
6507
+ symbol: baseline.fqName,
6508
+ old: {
6509
+ parameter: baseParam.publicName,
6510
+ position: String(baseParam.position)
6511
+ },
6512
+ new: {
6513
+ parameter: candParam.publicName,
6514
+ position: String(candParam.position)
6515
+ },
6516
+ message: `Parameter "${baseParam.publicName}" moved from position ${baseParam.position} to ${candParam.position} on "${baseline.displayName}"`,
6517
+ policy
6518
+ }));
6519
+ } else changes.push(makeChange({
6520
+ category: "constructor_reordered_named_friendly",
6521
+ symbol: baseline.fqName,
6522
+ old: {
6523
+ parameter: baseParam.publicName,
6524
+ position: String(baseParam.position)
6525
+ },
6526
+ new: {
6527
+ parameter: candParam.publicName,
6528
+ position: String(candParam.position)
6529
+ },
6530
+ message: `Parameter "${baseParam.publicName}" reordered on "${baseline.displayName}" (named-friendly language)`,
6531
+ policy
6532
+ }));
6533
+ }
6534
+ for (const candParam of candParams) if (!baseByName.has(candParam.publicName)) {
6535
+ if (changes.some((c) => c.category === "parameter_renamed" && c.new.parameter === candParam.publicName)) continue;
6536
+ const isTerminal = candParam.position === candParams.length - 1;
6537
+ const category = candParam.required ? "parameter_requiredness_increased" : isTerminal ? "parameter_added_optional_terminal" : "parameter_added_non_terminal_optional";
6538
+ if (candParam.required) changes.push(makeChange({
6539
+ category,
6540
+ symbol: baseline.fqName,
6541
+ old: { parameter: "(absent)" },
6542
+ new: {
6543
+ parameter: candParam.publicName,
6544
+ required: "true"
6545
+ },
6546
+ message: `Required parameter "${candParam.publicName}" added to "${baseline.displayName}"`,
6547
+ policy
6548
+ }));
6549
+ else changes.push(makeChange({
6550
+ category,
6551
+ symbol: baseline.fqName,
6552
+ old: { parameter: "(absent)" },
6553
+ new: { parameter: candParam.publicName },
6554
+ message: `Optional parameter "${candParam.publicName}" added to "${baseline.displayName}"`,
6555
+ policy
6556
+ }));
6557
+ }
6558
+ return changes;
6559
+ }
6560
+ function parameterNameIsPublicApi(param, policy, isConstructor) {
6561
+ if (param.sensitivity.publicName) return true;
6562
+ if (isConstructor) return policy.constructorParameterNamesArePublicApi;
6563
+ return policy.methodParameterNamesArePublicApi;
6564
+ }
6565
+ /** Build a deterministic conceptual change ID. */
6566
+ function buildConceptualChangeId(category, symbol, match) {
6567
+ const parts = [
6568
+ "chg",
6569
+ category,
6570
+ symbol.replace(/[^a-zA-Z0-9_.]/g, "_")
6571
+ ];
6572
+ if (match.parameter) parts.push(match.parameter);
6573
+ if (match.member) parts.push(match.member);
6574
+ return parts.join("_").toLowerCase();
6575
+ }
6576
+ function makeChange(opts) {
6577
+ return {
6578
+ category: opts.category,
6579
+ severity: opts.severityOverride ?? defaultSeverityForCategory(opts.category),
6580
+ symbol: opts.symbol,
6581
+ conceptualChangeId: buildConceptualChangeId(opts.category, opts.symbol, opts.old),
6582
+ provenance: opts.provenance ?? "unknown",
6583
+ old: opts.old,
6584
+ new: opts.new,
6585
+ message: opts.message
6586
+ };
6587
+ }
6588
+ /**
6589
+ * Classify a new symbol as additive.
6590
+ */
6591
+ function classifyAddedSymbol(symbol) {
6592
+ return {
6593
+ category: "symbol_added",
6594
+ severity: "additive",
6595
+ symbol: symbol.fqName,
6596
+ conceptualChangeId: buildConceptualChangeId("symbol_added", symbol.fqName, {}),
6597
+ provenance: "unknown",
6598
+ old: { symbol: "(absent)" },
6599
+ new: { symbol: symbol.fqName },
6600
+ message: `Symbol "${symbol.displayName}" was added`
6601
+ };
6602
+ }
6603
+ /** Summarize a list of classified changes by severity. */
6604
+ function summarizeChanges(changes) {
6605
+ let breaking = 0;
6606
+ let softRisk = 0;
6607
+ let additive = 0;
6608
+ for (const c of changes) if (c.severity === "breaking") breaking++;
6609
+ else if (c.severity === "soft-risk") softRisk++;
6610
+ else additive++;
6611
+ return {
6612
+ breaking,
6613
+ softRisk,
6614
+ additive
6615
+ };
6616
+ }
6617
+ //#endregion
6250
6618
  //#region src/compat/spec-filter.ts
6251
6619
  /**
6252
6620
  * Compute the set of symbol names that are derivable from the OpenAPI spec.
@@ -6412,378 +6780,609 @@ function filterSurface(surface, allowedNames, opts) {
6412
6780
  }
6413
6781
  //#endregion
6414
6782
  //#region src/compat/differ.ts
6415
- function diffSurfaces(baseline, candidate, hints) {
6416
- const violations = [];
6417
- const additions = [];
6418
- let totalBaseline = 0;
6419
- let preserved = 0;
6420
- for (const [name, baseClass] of Object.entries(baseline.classes)) {
6421
- totalBaseline++;
6422
- const candClass = candidate.classes[name];
6423
- if (!candClass) {
6424
- violations.push({
6425
- category: "public-api",
6426
- severity: "breaking",
6427
- symbolPath: name,
6428
- baseline: name,
6429
- candidate: "(missing)",
6430
- message: `Class "${name}" exists in baseline but not in generated output`
6431
- });
6432
- totalBaseline += Object.values(baseClass.methods).reduce((sum, overloads) => sum + overloads.length, 0);
6433
- totalBaseline += Object.keys(baseClass.properties).length;
6434
- continue;
6435
- }
6436
- preserved++;
6437
- for (const [methodName, baseOverloads] of Object.entries(baseClass.methods)) {
6438
- const candOverloads = candClass.methods[methodName];
6439
- for (const baseMethod of baseOverloads) {
6440
- totalBaseline++;
6441
- if (!candOverloads || candOverloads.length === 0) {
6442
- violations.push({
6443
- category: "public-api",
6444
- severity: "breaking",
6445
- symbolPath: `${name}.${methodName}`,
6446
- baseline: methodName,
6447
- candidate: "(missing)",
6448
- message: `Method "${name}.${methodName}" exists in baseline but not in generated output`
6449
- });
6450
- continue;
6451
- }
6452
- if (!candOverloads.find((c) => signaturesMatch(baseMethod, c))) {
6453
- if (hints.isSignatureEquivalent ? candOverloads.find((c) => hints.isSignatureEquivalent(baseMethod, c, candidate)) : void 0) {
6454
- preserved++;
6455
- continue;
6456
- }
6457
- violations.push({
6458
- category: "signature",
6459
- severity: "breaking",
6460
- symbolPath: `${name}.${methodName}`,
6461
- baseline: formatSignature(baseMethod),
6462
- candidate: formatSignature(candOverloads[0]),
6463
- message: `Signature mismatch for "${name}.${methodName}"`
6464
- });
6465
- continue;
6466
- }
6467
- preserved++;
6468
- }
6469
- }
6470
- for (const methodName of Object.keys(candClass.methods)) if (!baseClass.methods[methodName]) additions.push({
6471
- symbolPath: `${name}.${methodName}`,
6472
- symbolType: "method"
6473
- });
6474
- for (const [propName, baseProp] of Object.entries(baseClass.properties)) {
6475
- totalBaseline++;
6476
- const candProp = candClass.properties[propName];
6477
- if (!candProp) {
6478
- violations.push({
6479
- category: "public-api",
6480
- severity: "breaking",
6481
- symbolPath: `${name}.${propName}`,
6482
- baseline: baseProp.type,
6483
- candidate: "(missing)",
6484
- message: `Property "${name}.${propName}" exists in baseline but not in generated output`
6485
- });
6486
- continue;
6487
- }
6488
- if (baseProp.type !== candProp.type) {
6489
- const nullableOnly = hints.isNullableOnlyDifference(baseProp.type, candProp.type);
6490
- violations.push({
6491
- category: "signature",
6492
- severity: nullableOnly ? "warning" : "breaking",
6493
- symbolPath: `${name}.${propName}`,
6494
- baseline: baseProp.type,
6495
- candidate: candProp.type,
6496
- message: `Property type mismatch for "${name}.${propName}"`
6497
- });
6498
- if (nullableOnly) preserved++;
6499
- continue;
6500
- }
6501
- preserved++;
6502
- }
6503
- for (const propName of Object.keys(candClass.properties)) if (!baseClass.properties[propName]) additions.push({
6504
- symbolPath: `${name}.${propName}`,
6505
- symbolType: "property"
6783
+ /**
6784
+ * Diff two compat snapshots, producing classified changes with
6785
+ * policy-aware severity.
6786
+ */
6787
+ function diffSnapshots(baseline, candidate, policy) {
6788
+ const effectivePolicy = policy ?? baseline.policies;
6789
+ const changes = [];
6790
+ const candById = /* @__PURE__ */ new Map();
6791
+ const candByFqName = /* @__PURE__ */ new Map();
6792
+ for (const sym of candidate.symbols) {
6793
+ candById.set(sym.id, sym);
6794
+ candByFqName.set(sym.fqName, sym);
6795
+ }
6796
+ const baseByFqName = /* @__PURE__ */ new Set();
6797
+ for (const sym of baseline.symbols) baseByFqName.add(sym.fqName);
6798
+ for (const baseSym of baseline.symbols) {
6799
+ const candSym = candById.get(baseSym.id) ?? candByFqName.get(baseSym.fqName);
6800
+ changes.push(...classifySymbolChanges(baseSym, candSym, effectivePolicy));
6801
+ }
6802
+ for (const candSym of candidate.symbols) if (!baseByFqName.has(candSym.fqName)) changes.push(classifyAddedSymbol(candSym));
6803
+ return {
6804
+ changes,
6805
+ summary: summarizeChanges(changes)
6806
+ };
6807
+ }
6808
+ //#endregion
6809
+ //#region src/compat/concepts.ts
6810
+ /**
6811
+ * Group classified changes from multiple languages into conceptual changes.
6812
+ *
6813
+ * Changes are grouped by their `conceptualChangeId` — a deterministic ID
6814
+ * derived from the symbol, category, and match details. Changes with the
6815
+ * same ID across different languages represent the same conceptual change.
6816
+ */
6817
+ function buildConceptualRollup(perLanguageResults) {
6818
+ const grouped = /* @__PURE__ */ new Map();
6819
+ for (const { diff: result, language } of perLanguageResults) for (const change of result.changes) {
6820
+ const existing = grouped.get(change.conceptualChangeId);
6821
+ if (existing) existing.impact[language] = change.severity;
6822
+ else grouped.set(change.conceptualChangeId, {
6823
+ id: change.conceptualChangeId,
6824
+ symbol: change.symbol,
6825
+ category: change.category,
6826
+ impact: { [language]: change.severity },
6827
+ message: change.message,
6828
+ old: change.old,
6829
+ new: change.new
6506
6830
  });
6507
6831
  }
6508
- for (const name of Object.keys(candidate.classes)) if (!baseline.classes[name]) additions.push({
6509
- symbolPath: name,
6510
- symbolType: "class"
6511
- });
6512
- const candIfaceFieldSets = /* @__PURE__ */ new Map();
6513
- for (const [n, iface] of Object.entries(candidate.interfaces)) candIfaceFieldSets.set(n, new Set(Object.keys(iface.fields).map((f) => f.toLowerCase())));
6514
- const candClassPropSets = /* @__PURE__ */ new Map();
6515
- for (const [n, cls] of Object.entries(candidate.classes)) candClassPropSets.set(n, new Set(Object.keys(cls.properties).map((f) => f.toLowerCase())));
6516
- for (const [name, baseIface] of Object.entries(baseline.interfaces)) {
6517
- totalBaseline++;
6518
- const candIface = candidate.interfaces[name];
6519
- if (!candIface) {
6520
- let tolerated = false;
6521
- if (hints.tolerateCategoryMismatch && name.startsWith("Serialized")) {
6522
- const baseName = name.slice(10);
6523
- if (candidate.interfaces[baseName] || candidate.classes[baseName]) tolerated = true;
6524
- }
6525
- if (tolerated) {
6526
- preserved++;
6527
- totalBaseline += Object.keys(baseIface.fields).length;
6528
- preserved += Object.keys(baseIface.fields).length;
6529
- continue;
6530
- }
6531
- if (!tolerated) {
6532
- const baseFieldNamesLower = new Set(Object.keys(baseIface.fields).map((f) => f.toLowerCase()));
6533
- if (baseFieldNamesLower.size > 0) {
6534
- for (const [, candFieldNamesLower] of candIfaceFieldSets) if (candFieldNamesLower.size === baseFieldNamesLower.size && [...baseFieldNamesLower].every((f) => candFieldNamesLower.has(f))) {
6535
- tolerated = true;
6536
- break;
6537
- }
6538
- if (!tolerated) {
6539
- for (const [, candPropNamesLower] of candClassPropSets) if (candPropNamesLower.size === baseFieldNamesLower.size && [...baseFieldNamesLower].every((f) => candPropNamesLower.has(f))) {
6540
- tolerated = true;
6541
- break;
6542
- }
6543
- }
6544
- } else tolerated = true;
6545
- }
6546
- if (tolerated) {
6547
- preserved++;
6548
- totalBaseline += Object.keys(baseIface.fields).length;
6549
- preserved += Object.keys(baseIface.fields).length;
6550
- continue;
6551
- }
6552
- violations.push({
6553
- category: "public-api",
6554
- severity: "breaking",
6555
- symbolPath: name,
6556
- baseline: name,
6557
- candidate: "(missing)",
6558
- message: `Interface "${name}" exists in baseline but not in generated output`
6559
- });
6560
- totalBaseline += Object.keys(baseIface.fields).length;
6561
- continue;
6562
- }
6563
- preserved++;
6564
- for (const [fieldName, baseField] of Object.entries(baseIface.fields)) {
6565
- totalBaseline++;
6566
- const candField = candIface.fields[fieldName];
6567
- if (!candField) {
6568
- const baseTypeClean = baseField.type.replace(/\[\]$/, "").replace(/ \| null$/, "");
6569
- const typeIsUnresolvable = NAMED_TYPE_RE.test(baseTypeClean) && !typeExistsInSurface(baseTypeClean, candidate);
6570
- violations.push({
6571
- category: "public-api",
6572
- severity: typeIsUnresolvable ? "warning" : "breaking",
6573
- symbolPath: `${name}.${fieldName}`,
6574
- baseline: baseField.type,
6575
- candidate: "(missing)",
6576
- message: `Field "${name}.${fieldName}" exists in baseline but not in generated output`
6577
- });
6578
- if (typeIsUnresolvable) preserved++;
6579
- continue;
6580
- }
6581
- if (baseField.type !== candField.type) {
6582
- if (hints.isUnionReorder(baseField.type, candField.type)) {
6583
- preserved++;
6584
- continue;
6585
- }
6586
- if (hints.isTypeEquivalent?.(baseField.type, candField.type, candidate)) {
6587
- preserved++;
6588
- continue;
6589
- }
6590
- const nullableOnly = hints.isNullableOnlyDifference(baseField.type, candField.type);
6591
- const genericParam = hints.isGenericTypeParam(baseField.type);
6592
- const extractionArtifact = hints.isExtractionArtifact(candField.type);
6593
- const isWarning = nullableOnly || genericParam || extractionArtifact;
6594
- violations.push({
6595
- category: "signature",
6596
- severity: isWarning ? "warning" : "breaking",
6597
- symbolPath: `${name}.${fieldName}`,
6598
- baseline: baseField.type,
6599
- candidate: candField.type,
6600
- message: `Field type mismatch for "${name}.${fieldName}"`
6601
- });
6602
- if (isWarning) preserved++;
6603
- continue;
6604
- }
6605
- preserved++;
6606
- }
6607
- for (const fieldName of Object.keys(candIface.fields)) if (!baseIface.fields[fieldName]) additions.push({
6608
- symbolPath: `${name}.${fieldName}`,
6609
- symbolType: "property"
6610
- });
6832
+ return { conceptualChanges: [...grouped.values()] };
6833
+ }
6834
+ /** Get the highest severity across all languages for a conceptual change. */
6835
+ function highestSeverity(change) {
6836
+ const severities = Object.values(change.impact);
6837
+ if (severities.includes("breaking")) return "breaking";
6838
+ if (severities.includes("soft-risk")) return "soft-risk";
6839
+ return "additive";
6840
+ }
6841
+ /** Count conceptual changes by highest severity. */
6842
+ function summarizeConceptualChanges(rollup) {
6843
+ let breaking = 0;
6844
+ let softRisk = 0;
6845
+ let additive = 0;
6846
+ for (const change of rollup.conceptualChanges) {
6847
+ const sev = highestSeverity(change);
6848
+ if (sev === "breaking") breaking++;
6849
+ else if (sev === "soft-risk") softRisk++;
6850
+ else additive++;
6611
6851
  }
6612
- for (const name of Object.keys(candidate.interfaces)) if (!baseline.interfaces[name]) additions.push({
6613
- symbolPath: name,
6614
- symbolType: "interface"
6615
- });
6616
- for (const [name, baseAlias] of Object.entries(baseline.typeAliases)) {
6617
- totalBaseline++;
6618
- const candAlias = candidate.typeAliases[name];
6619
- if (!candAlias) {
6620
- if (hints.tolerateCategoryMismatch && typeExistsInSurface(name, candidate)) {
6621
- preserved++;
6622
- continue;
6623
- }
6624
- violations.push({
6625
- category: "public-api",
6626
- severity: "breaking",
6627
- symbolPath: name,
6628
- baseline: baseAlias.value,
6629
- candidate: "(missing)",
6630
- message: `Type alias "${name}" exists in baseline but not in generated output`
6631
- });
6632
- continue;
6633
- }
6634
- if (baseAlias.value !== candAlias.value) {
6635
- if (hints.isUnionReorder(baseAlias.value, candAlias.value)) {
6636
- preserved++;
6637
- continue;
6638
- }
6639
- const nullableOnly = hints.isNullableOnlyDifference(baseAlias.value, candAlias.value);
6640
- violations.push({
6641
- category: "signature",
6642
- severity: nullableOnly ? "warning" : "breaking",
6643
- symbolPath: name,
6644
- baseline: baseAlias.value,
6645
- candidate: candAlias.value,
6646
- message: `Type alias value mismatch for "${name}"`
6647
- });
6648
- if (nullableOnly) preserved++;
6649
- continue;
6852
+ return {
6853
+ breaking,
6854
+ softRisk,
6855
+ additive
6856
+ };
6857
+ }
6858
+ //#endregion
6859
+ //#region src/compat/report.ts
6860
+ /** Generate a machine-readable compat report from a diff result. */
6861
+ function generateReport(diff, language) {
6862
+ return {
6863
+ schemaVersion: "1",
6864
+ language: language ?? "unknown",
6865
+ summary: diff.summary,
6866
+ changes: diff.changes.map((c) => ({
6867
+ severity: c.severity,
6868
+ category: c.category,
6869
+ symbol: c.symbol,
6870
+ conceptualChangeId: c.conceptualChangeId,
6871
+ provenance: c.provenance,
6872
+ old: c.old,
6873
+ new: c.new,
6874
+ ...c.message ? { message: c.message } : {}
6875
+ }))
6876
+ };
6877
+ }
6878
+ /** Generate a human-readable summary string for terminal output. */
6879
+ function formatHumanSummary(diff, opts) {
6880
+ const lines = [];
6881
+ lines.push(`Compat report${opts?.language ? ` for ${opts.language}` : ""}:`);
6882
+ lines.push(` ${diff.summary.breaking} breaking, ${diff.summary.softRisk} soft-risk, ${diff.summary.additive} additive`);
6883
+ if (diff.changes.length === 0) {
6884
+ lines.push(" No compatibility changes detected.");
6885
+ return lines.join("\n");
6886
+ }
6887
+ const breaking = diff.changes.filter((c) => c.severity === "breaking");
6888
+ const softRisk = diff.changes.filter((c) => c.severity === "soft-risk");
6889
+ const additive = diff.changes.filter((c) => c.severity === "additive");
6890
+ if (breaking.length > 0) {
6891
+ lines.push("");
6892
+ lines.push(" Breaking:");
6893
+ for (const c of breaking) {
6894
+ lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
6895
+ if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
6650
6896
  }
6651
- preserved++;
6652
6897
  }
6653
- for (const name of Object.keys(candidate.typeAliases)) if (!baseline.typeAliases[name]) additions.push({
6654
- symbolPath: name,
6655
- symbolType: "type-alias"
6656
- });
6657
- for (const [name, baseEnum] of Object.entries(baseline.enums)) {
6658
- totalBaseline++;
6659
- const candEnum = candidate.enums[name];
6660
- if (!candEnum) {
6661
- violations.push({
6662
- category: "public-api",
6663
- severity: "breaking",
6664
- symbolPath: name,
6665
- baseline: name,
6666
- candidate: "(missing)",
6667
- message: `Enum "${name}" exists in baseline but not in generated output`
6668
- });
6669
- continue;
6898
+ if (softRisk.length > 0) {
6899
+ lines.push("");
6900
+ lines.push(" Soft-risk:");
6901
+ for (const c of softRisk) {
6902
+ lines.push(` [${c.category}] ${c.symbol} — ${c.message}`);
6903
+ if (opts?.explain && c.provenance !== "unknown") lines.push(` provenance: ${c.provenance}`);
6670
6904
  }
6671
- const candValueToMembers = /* @__PURE__ */ new Map();
6672
- for (const [candMember, candValue] of Object.entries(candEnum.members)) {
6673
- const existing = candValueToMembers.get(candValue);
6674
- if (existing) existing.push(candMember);
6675
- else candValueToMembers.set(candValue, [candMember]);
6676
- }
6677
- let enumMatch = true;
6678
- for (const [member, value] of Object.entries(baseEnum.members)) {
6679
- if (candEnum.members[member] === value) continue;
6680
- const valueMatches = candValueToMembers.get(value);
6681
- if (valueMatches && valueMatches.length > 0) {
6682
- violations.push({
6683
- category: "signature",
6684
- severity: "warning",
6685
- symbolPath: `${name}.${member}`,
6686
- baseline: `${member}=${String(value)}`,
6687
- candidate: `${valueMatches[0]}=${String(value)}`,
6688
- message: `Enum member name differs for "${name}.${member}" (value "${value}" preserved as "${valueMatches[0]}")`
6689
- });
6690
- continue;
6691
- }
6692
- const lowerValue = String(value).toLowerCase();
6693
- const caseInsensitiveMatch = [...candValueToMembers.entries()].find(([candVal]) => String(candVal).toLowerCase() === lowerValue);
6694
- if (caseInsensitiveMatch) {
6695
- violations.push({
6696
- category: "signature",
6697
- severity: "warning",
6698
- symbolPath: `${name}.${member}`,
6699
- baseline: `${member}=${String(value)}`,
6700
- candidate: `${caseInsensitiveMatch[1][0]}=${String(caseInsensitiveMatch[0])}`,
6701
- message: `Enum member value case differs for "${name}.${member}" (baseline "${value}" vs candidate "${caseInsensitiveMatch[0]}")`
6702
- });
6703
- continue;
6704
- }
6705
- if (String(value) === member || member === "JsonEnumDefaultValue" || member === "JsonProperty") {
6706
- violations.push({
6707
- category: "signature",
6708
- severity: "warning",
6709
- symbolPath: `${name}.${member}`,
6710
- baseline: String(value),
6711
- candidate: "(extraction artifact)",
6712
- message: `Enum member "${name}.${member}" appears to be an extraction artifact`
6713
- });
6714
- continue;
6715
- }
6716
- violations.push({
6717
- category: "signature",
6718
- severity: "breaking",
6719
- symbolPath: `${name}.${member}`,
6720
- baseline: String(value),
6721
- candidate: member in candEnum.members ? String(candEnum.members[member]) : "(missing)",
6722
- message: `Enum member mismatch for "${name}.${member}"`
6905
+ }
6906
+ if (additive.length > 0) {
6907
+ lines.push("");
6908
+ lines.push(` Additive: ${additive.length} new symbol(s)`);
6909
+ }
6910
+ return lines.join("\n");
6911
+ }
6912
+ //#endregion
6913
+ //#region src/cli/compat-diff.ts
6914
+ async function compatDiffCommand(opts) {
6915
+ const baselineData = JSON.parse(readFileSync(opts.baseline, "utf-8"));
6916
+ const candidateData = JSON.parse(readFileSync(opts.candidate, "utf-8"));
6917
+ if (!validateSnapshot(baselineData)) throw new Error(`Invalid baseline snapshot: ${opts.baseline}`);
6918
+ if (!validateSnapshot(candidateData)) throw new Error(`Invalid candidate snapshot: ${opts.candidate}`);
6919
+ const diff = diffSnapshots(baselineData, candidateData);
6920
+ console.log(formatHumanSummary(diff, { explain: opts.explain }));
6921
+ if (opts.output) {
6922
+ const report = generateReport(diff);
6923
+ writeFileSync(opts.output, JSON.stringify(report, null, 2));
6924
+ console.log(`Report written to ${opts.output}`);
6925
+ }
6926
+ const failOn = opts.failOn ?? "breaking";
6927
+ if (diff.changes.some((c) => severityMeetsThreshold(c.severity, failOn))) process.exitCode = 1;
6928
+ }
6929
+ //#endregion
6930
+ //#region src/cli/compat-summary.ts
6931
+ /**
6932
+ * Format compat report(s) as a markdown PR comment.
6933
+ *
6934
+ * Single report:
6935
+ * oagen compat-summary --report node-report.json | gh pr comment --body-file -
6936
+ *
6937
+ * Multiple reports (cross-language rollup):
6938
+ * oagen compat-summary --report php.json --report python.json --report go.json
6939
+ */
6940
+ async function compatSummaryCommand(opts) {
6941
+ const reports = (Array.isArray(opts.report) ? opts.report : [opts.report]).map((p) => JSON.parse(readFileSync(p, "utf-8")));
6942
+ const md = reports.length === 1 ? formatSingleReport(reports[0]) : formatCrossLanguageRollup(reports);
6943
+ if (opts.output) {
6944
+ writeFileSync(opts.output, md);
6945
+ console.log(`Summary written to ${opts.output}`);
6946
+ } else process.stdout.write(md);
6947
+ }
6948
+ function formatSingleReport(report) {
6949
+ const lines = [];
6950
+ const { breaking, softRisk, additive } = report.summary;
6951
+ const total = breaking + softRisk + additive;
6952
+ if (breaking > 0) lines.push(`## :x: Compat check failed — ${report.language}`);
6953
+ else if (softRisk > 0) lines.push(`## :warning: Compat check has warnings — ${report.language}`);
6954
+ else lines.push(`## :white_check_mark: Compat check passed — ${report.language}`);
6955
+ lines.push("");
6956
+ lines.push("| Severity | Count |");
6957
+ lines.push("| --- | --- |");
6958
+ lines.push(`| Breaking | ${breaking} |`);
6959
+ lines.push(`| Soft-risk | ${softRisk} |`);
6960
+ lines.push(`| Additive | ${additive} |`);
6961
+ lines.push(`| **Total** | **${total}** |`);
6962
+ if (total === 0) {
6963
+ lines.push("");
6964
+ lines.push("No compatibility changes detected.");
6965
+ return lines.join("\n") + "\n";
6966
+ }
6967
+ const breakingChanges = report.changes.filter((c) => c.severity === "breaking");
6968
+ if (breakingChanges.length > 0) {
6969
+ lines.push("");
6970
+ lines.push("### Breaking changes");
6971
+ lines.push("");
6972
+ lines.push(formatChangesTable(breakingChanges));
6973
+ }
6974
+ const softRiskChanges = report.changes.filter((c) => c.severity === "soft-risk");
6975
+ if (softRiskChanges.length > 0) {
6976
+ lines.push("");
6977
+ lines.push("<details>");
6978
+ lines.push(`<summary>Soft-risk changes (${softRiskChanges.length})</summary>`);
6979
+ lines.push("");
6980
+ lines.push(formatChangesTable(softRiskChanges));
6981
+ lines.push("</details>");
6982
+ }
6983
+ const additiveChanges = report.changes.filter((c) => c.severity === "additive");
6984
+ if (additiveChanges.length > 0) {
6985
+ lines.push("");
6986
+ lines.push("<details>");
6987
+ lines.push(`<summary>Additive changes (${additiveChanges.length})</summary>`);
6988
+ lines.push("");
6989
+ lines.push(formatChangesTable(additiveChanges));
6990
+ lines.push("</details>");
6991
+ }
6992
+ return lines.join("\n") + "\n";
6993
+ }
6994
+ function formatCrossLanguageRollup(reports) {
6995
+ const languages = reports.map((r) => r.language);
6996
+ const rollup = buildConceptualRollup(reports.map((r) => ({
6997
+ diff: {
6998
+ changes: r.changes,
6999
+ summary: r.summary
7000
+ },
7001
+ language: r.language
7002
+ })));
7003
+ const summary = summarizeConceptualChanges(rollup);
7004
+ const lines = [];
7005
+ if (summary.breaking > 0) lines.push(`## :x: Compat check failed — ${summary.breaking} breaking across ${languages.length} languages`);
7006
+ else if (summary.softRisk > 0) lines.push(`## :warning: Compat check has warnings — ${summary.softRisk} soft-risk across ${languages.length} languages`);
7007
+ else lines.push(`## :white_check_mark: Compat check passed — ${languages.length} languages`);
7008
+ lines.push("");
7009
+ lines.push("| Language | Breaking | Soft-risk | Additive |");
7010
+ lines.push("| --- | --- | --- | --- |");
7011
+ for (const r of reports) lines.push(`| ${r.language} | ${r.summary.breaking} | ${r.summary.softRisk} | ${r.summary.additive} |`);
7012
+ if (rollup.conceptualChanges.length === 0) {
7013
+ lines.push("");
7014
+ lines.push("No compatibility changes detected.");
7015
+ return lines.join("\n") + "\n";
7016
+ }
7017
+ const breakingConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "breaking");
7018
+ if (breakingConcepts.length > 0) {
7019
+ lines.push("");
7020
+ lines.push("### Breaking changes");
7021
+ lines.push("");
7022
+ lines.push(formatConceptualTable(breakingConcepts, languages));
7023
+ }
7024
+ const softRiskConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "soft-risk");
7025
+ if (softRiskConcepts.length > 0) {
7026
+ lines.push("");
7027
+ lines.push("<details>");
7028
+ lines.push(`<summary>Soft-risk changes (${softRiskConcepts.length})</summary>`);
7029
+ lines.push("");
7030
+ lines.push(formatConceptualTable(softRiskConcepts, languages));
7031
+ lines.push("</details>");
7032
+ }
7033
+ const additiveConcepts = rollup.conceptualChanges.filter((c) => highestSeverity(c) === "additive");
7034
+ if (additiveConcepts.length > 0) {
7035
+ lines.push("");
7036
+ lines.push("<details>");
7037
+ lines.push(`<summary>Additive changes (${additiveConcepts.length})</summary>`);
7038
+ lines.push("");
7039
+ lines.push(formatConceptualTable(additiveConcepts, languages));
7040
+ lines.push("</details>");
7041
+ }
7042
+ return lines.join("\n") + "\n";
7043
+ }
7044
+ function formatConceptualTable(concepts, languages) {
7045
+ const lines = [];
7046
+ const langHeaders = languages.map((l) => l).join(" | ");
7047
+ lines.push(`| Symbol | Category | ${langHeaders} |`);
7048
+ lines.push(`| --- | --- | ${languages.map(() => "---").join(" | ")} |`);
7049
+ for (const c of concepts) {
7050
+ const severities = languages.map((l) => severityIcon(c.impact[l])).join(" | ");
7051
+ lines.push(`| \`${c.symbol}\` | \`${c.category}\` | ${severities} |`);
7052
+ }
7053
+ return lines.join("\n");
7054
+ }
7055
+ function severityIcon(severity) {
7056
+ if (!severity) return "—";
7057
+ switch (severity) {
7058
+ case "breaking": return ":x: breaking";
7059
+ case "soft-risk": return ":warning: soft-risk";
7060
+ case "additive": return ":white_check_mark: additive";
7061
+ }
7062
+ }
7063
+ function formatChangesTable(changes) {
7064
+ const lines = [];
7065
+ lines.push("| Category | Symbol | Detail |");
7066
+ lines.push("| --- | --- | --- |");
7067
+ for (const c of changes) {
7068
+ const detail = c.message ?? formatOldNew(c.old, c.new);
7069
+ lines.push(`| \`${c.category}\` | \`${c.symbol}\` | ${escapeMarkdown(detail)} |`);
7070
+ }
7071
+ return lines.join("\n");
7072
+ }
7073
+ function formatOldNew(old, updated) {
7074
+ const parts = [];
7075
+ for (const key of Object.keys(old)) if (updated[key] && old[key] !== updated[key]) parts.push(`${key}: \`${old[key]}\` → \`${updated[key]}\``);
7076
+ return parts.length > 0 ? parts.join(", ") : "";
7077
+ }
7078
+ function escapeMarkdown(text) {
7079
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
7080
+ }
7081
+ //#endregion
7082
+ //#region src/compat/approvals.ts
7083
+ /**
7084
+ * Match a classified change against a list of approvals.
7085
+ * Returns the first matching approval, or null if none match.
7086
+ */
7087
+ function matchApproval(change, approvals, language) {
7088
+ for (const approval of approvals) if (approvalMatchesChange(approval, change, language)) return approval;
7089
+ return null;
7090
+ }
7091
+ /**
7092
+ * Filter changes to only those NOT covered by approvals.
7093
+ * These are the remaining unapproved changes that may cause failure.
7094
+ */
7095
+ function unapprovedChanges(changes, approvals, language) {
7096
+ return changes.filter((change) => !matchApproval(change, approvals, language));
7097
+ }
7098
+ function approvalMatchesChange(approval, change, language) {
7099
+ if (approval.approved === false) return false;
7100
+ if (approval.category !== change.category) return false;
7101
+ if (!symbolMatches(approval.symbol, change.symbol)) return false;
7102
+ if (!languageMatches(approval.appliesTo, language)) return false;
7103
+ if (approval.match && !narrowingMatches(approval.match, change)) return false;
7104
+ return true;
7105
+ }
7106
+ function symbolMatches(approvalSymbol, changeSymbol) {
7107
+ if (approvalSymbol === changeSymbol) return true;
7108
+ return approvalSymbol.replace(/::/g, ".").replace(/\\/g, ".") === changeSymbol.replace(/::/g, ".").replace(/\\/g, ".");
7109
+ }
7110
+ function languageMatches(appliesTo, language) {
7111
+ if (!appliesTo || appliesTo === "all-impacted-languages") return true;
7112
+ return appliesTo.includes(language);
7113
+ }
7114
+ function narrowingMatches(match, change) {
7115
+ if (match.parameter) {
7116
+ const paramInOld = change.old.parameter === match.parameter;
7117
+ const paramInNew = change.new.parameter === match.parameter;
7118
+ if (!paramInOld && !paramInNew) return false;
7119
+ }
7120
+ if (match.member) {
7121
+ const memberInOld = change.old.member === match.member;
7122
+ const memberInNew = change.new.member === match.member;
7123
+ if (!memberInOld && !memberInNew) return false;
7124
+ }
7125
+ if (match.oldName) {
7126
+ if (!Object.values(change.old).some((v) => v === match.oldName)) return false;
7127
+ }
7128
+ if (match.newName) {
7129
+ if (!Object.values(change.new).some((v) => v === match.newName)) return false;
7130
+ }
7131
+ return true;
7132
+ }
7133
+ //#endregion
7134
+ //#region src/compat/policy.ts
7135
+ /** Built-in language defaults. */
7136
+ const LANGUAGE_DEFAULTS = {
7137
+ php: {
7138
+ callerUsesParamNames: true,
7139
+ constructorOrderMatters: true,
7140
+ methodParameterNamesArePublicApi: true,
7141
+ constructorParameterNamesArePublicApi: true,
7142
+ overloadsArePublicApi: false,
7143
+ arityIsPublicApi: false
7144
+ },
7145
+ python: {
7146
+ callerUsesParamNames: true,
7147
+ constructorOrderMatters: true,
7148
+ methodParameterNamesArePublicApi: true,
7149
+ constructorParameterNamesArePublicApi: true,
7150
+ overloadsArePublicApi: false,
7151
+ arityIsPublicApi: false
7152
+ },
7153
+ ruby: {
7154
+ callerUsesParamNames: true,
7155
+ constructorOrderMatters: true,
7156
+ methodParameterNamesArePublicApi: true,
7157
+ constructorParameterNamesArePublicApi: true,
7158
+ overloadsArePublicApi: false,
7159
+ arityIsPublicApi: false
7160
+ },
7161
+ go: {
7162
+ callerUsesParamNames: false,
7163
+ constructorOrderMatters: true,
7164
+ methodParameterNamesArePublicApi: false,
7165
+ constructorParameterNamesArePublicApi: false,
7166
+ overloadsArePublicApi: false,
7167
+ arityIsPublicApi: true
7168
+ },
7169
+ kotlin: {
7170
+ callerUsesParamNames: true,
7171
+ constructorOrderMatters: false,
7172
+ methodParameterNamesArePublicApi: true,
7173
+ constructorParameterNamesArePublicApi: true,
7174
+ overloadsArePublicApi: true,
7175
+ arityIsPublicApi: false
7176
+ },
7177
+ dotnet: {
7178
+ callerUsesParamNames: true,
7179
+ constructorOrderMatters: false,
7180
+ methodParameterNamesArePublicApi: true,
7181
+ constructorParameterNamesArePublicApi: true,
7182
+ overloadsArePublicApi: true,
7183
+ arityIsPublicApi: false
7184
+ },
7185
+ elixir: {
7186
+ callerUsesParamNames: true,
7187
+ constructorOrderMatters: false,
7188
+ methodParameterNamesArePublicApi: true,
7189
+ constructorParameterNamesArePublicApi: false,
7190
+ overloadsArePublicApi: false,
7191
+ arityIsPublicApi: true
7192
+ },
7193
+ rust: {
7194
+ callerUsesParamNames: false,
7195
+ constructorOrderMatters: true,
7196
+ methodParameterNamesArePublicApi: false,
7197
+ constructorParameterNamesArePublicApi: false,
7198
+ overloadsArePublicApi: false,
7199
+ arityIsPublicApi: true
7200
+ },
7201
+ node: {
7202
+ callerUsesParamNames: false,
7203
+ constructorOrderMatters: false,
7204
+ methodParameterNamesArePublicApi: false,
7205
+ constructorParameterNamesArePublicApi: false,
7206
+ overloadsArePublicApi: false,
7207
+ arityIsPublicApi: false
7208
+ }
7209
+ };
7210
+ /** Get the built-in default policy for a language. */
7211
+ function getDefaultPolicy(language) {
7212
+ return { ...LANGUAGE_DEFAULTS[language] };
7213
+ }
7214
+ /** Merge user overrides onto language defaults. Only provided keys are overridden. */
7215
+ function mergePolicy(defaults, overrides) {
7216
+ return {
7217
+ ...defaults,
7218
+ ...overrides
7219
+ };
7220
+ }
7221
+ //#endregion
7222
+ //#region src/compat/ir.ts
7223
+ /**
7224
+ * Convert a legacy ApiSurface to a CompatSnapshot.
7225
+ *
7226
+ * This bridge allows existing extractors (which produce ApiSurface) to feed
7227
+ * into the new classified diff engine without requiring immediate migration.
7228
+ */
7229
+ function apiSurfaceToSnapshot(surface) {
7230
+ const language = surface.language || "node";
7231
+ const symbols = [];
7232
+ for (const [className, cls] of Object.entries(surface.classes)) {
7233
+ symbols.push({
7234
+ id: `class:${className}`,
7235
+ kind: "service_accessor",
7236
+ fqName: className,
7237
+ displayName: className,
7238
+ visibility: "public",
7239
+ stability: cls.deprecationMessage ? "deprecated" : "stable",
7240
+ sourceKind: "generated_service_wrapper"
7241
+ });
7242
+ if (cls.constructorParams.length > 0) symbols.push({
7243
+ id: `ctor:${className}`,
7244
+ kind: "constructor",
7245
+ fqName: `${className}.constructor`,
7246
+ ownerFqName: className,
7247
+ displayName: `new ${className}`,
7248
+ visibility: "public",
7249
+ stability: "stable",
7250
+ sourceKind: "generated_resource_constructor",
7251
+ parameters: cls.constructorParams.map((p, i) => apiParamToCompatParam(p, i, language))
7252
+ });
7253
+ for (const [methodName, overloads] of Object.entries(cls.methods)) for (let oi = 0; oi < overloads.length; oi++) {
7254
+ const method = overloads[oi];
7255
+ const suffix = overloads.length > 1 ? `#${oi}` : "";
7256
+ symbols.push({
7257
+ id: `method:${className}.${methodName}${suffix}`,
7258
+ kind: "callable",
7259
+ fqName: `${className}.${methodName}`,
7260
+ ownerFqName: className,
7261
+ displayName: `${className}.${methodName}`,
7262
+ visibility: "public",
7263
+ stability: "stable",
7264
+ sourceKind: "generated_service_wrapper",
7265
+ parameters: method.params.map((p, i) => apiParamToCompatParam(p, i, language)),
7266
+ returns: { name: method.returnType }
6723
7267
  });
6724
- enumMatch = false;
6725
7268
  }
6726
- if (enumMatch) preserved++;
7269
+ for (const [propName, prop] of Object.entries(cls.properties)) symbols.push({
7270
+ id: `prop:${className}.${propName}`,
7271
+ kind: "property",
7272
+ fqName: `${className}.${propName}`,
7273
+ ownerFqName: className,
7274
+ displayName: `${className}.${propName}`,
7275
+ visibility: "public",
7276
+ stability: "stable",
7277
+ sourceKind: "generated_service_wrapper",
7278
+ typeRef: { name: prop.type }
7279
+ });
7280
+ }
7281
+ for (const [ifaceName, iface] of Object.entries(surface.interfaces)) {
7282
+ symbols.push({
7283
+ id: `iface:${ifaceName}`,
7284
+ kind: "alias",
7285
+ fqName: ifaceName,
7286
+ displayName: ifaceName,
7287
+ visibility: "public",
7288
+ stability: "stable",
7289
+ sourceKind: "generated_resource_constructor"
7290
+ });
7291
+ for (const [fieldName, field] of Object.entries(iface.fields)) symbols.push({
7292
+ id: `field:${ifaceName}.${fieldName}`,
7293
+ kind: "field",
7294
+ fqName: `${ifaceName}.${fieldName}`,
7295
+ ownerFqName: ifaceName,
7296
+ displayName: `${ifaceName}.${fieldName}`,
7297
+ visibility: "public",
7298
+ stability: "stable",
7299
+ sourceKind: "generated_resource_constructor",
7300
+ typeRef: { name: field.type }
7301
+ });
7302
+ if (iface.hasCustomConstructor) symbols.push({
7303
+ id: `ctor:${ifaceName}`,
7304
+ kind: "constructor",
7305
+ fqName: `${ifaceName}.constructor`,
7306
+ ownerFqName: ifaceName,
7307
+ displayName: `new ${ifaceName}`,
7308
+ visibility: "public",
7309
+ stability: "stable",
7310
+ sourceKind: "generated_resource_constructor"
7311
+ });
6727
7312
  }
6728
- for (const name of Object.keys(candidate.enums)) if (!baseline.enums[name]) additions.push({
6729
- symbolPath: name,
6730
- symbolType: "enum"
7313
+ for (const [aliasName] of Object.entries(surface.typeAliases)) symbols.push({
7314
+ id: `alias:${aliasName}`,
7315
+ kind: "alias",
7316
+ fqName: aliasName,
7317
+ displayName: aliasName,
7318
+ visibility: "public",
7319
+ stability: "stable",
7320
+ sourceKind: "generated_resource_constructor"
6731
7321
  });
6732
- for (const [path, baseExports] of Object.entries(baseline.exports)) {
6733
- const candExports = candidate.exports[path];
6734
- if (!candExports) {
6735
- for (const exp of baseExports) violations.push({
6736
- category: "export-structure",
6737
- severity: "warning",
6738
- symbolPath: `exports[${path}].${exp}`,
6739
- baseline: exp,
6740
- candidate: "(missing)",
6741
- message: `Export "${exp}" from "${path}" not found in generated output`
6742
- });
6743
- continue;
6744
- }
6745
- const candSet = new Set(candExports);
6746
- for (const exp of baseExports) if (!candSet.has(exp)) violations.push({
6747
- category: "export-structure",
6748
- severity: "warning",
6749
- symbolPath: `exports[${path}].${exp}`,
6750
- baseline: exp,
6751
- candidate: "(missing)",
6752
- message: `Export "${exp}" from "${path}" not found in generated output`
7322
+ for (const [enumName, enumDef] of Object.entries(surface.enums)) {
7323
+ symbols.push({
7324
+ id: `enum:${enumName}`,
7325
+ kind: "enum",
7326
+ fqName: enumName,
7327
+ displayName: enumName,
7328
+ visibility: "public",
7329
+ stability: "stable",
7330
+ sourceKind: "generated_enum"
7331
+ });
7332
+ for (const [memberName, memberValue] of Object.entries(enumDef.members)) symbols.push({
7333
+ id: `enum_member:${enumName}.${memberName}`,
7334
+ kind: "enum_member",
7335
+ fqName: `${enumName}.${memberName}`,
7336
+ ownerFqName: enumName,
7337
+ displayName: `${enumName}.${memberName}`,
7338
+ visibility: "public",
7339
+ stability: "stable",
7340
+ sourceKind: "generated_enum",
7341
+ value: memberValue
6753
7342
  });
6754
7343
  }
6755
7344
  return {
6756
- preservationScore: totalBaseline > 0 ? Math.round(preserved / totalBaseline * 100) : 100,
6757
- totalBaselineSymbols: totalBaseline,
6758
- preservedSymbols: preserved,
6759
- violations,
6760
- additions
7345
+ schemaVersion: "1",
7346
+ source: { extractedAt: surface.extractedAt },
7347
+ policies: getDefaultPolicy(language),
7348
+ symbols
6761
7349
  };
6762
7350
  }
6763
- /**
6764
- * Compare method signatures using raw type strings.
6765
- * NOTE: This only produces meaningful results when both surfaces were extracted
6766
- * by the same extractor for the same language. Cross-language or cross-extractor
6767
- * comparisons will always fail because type representations differ.
6768
- */
6769
- function signaturesMatch(baseline, candidate) {
6770
- if (baseline.returnType !== candidate.returnType) return false;
6771
- for (let i = 0; i < baseline.params.length; i++) {
6772
- const baseParam = baseline.params[i];
6773
- const candParam = candidate.params[i];
6774
- if (!candParam) return false;
6775
- if (baseParam.type !== candParam.type) return false;
6776
- if (baseParam.name !== candParam.name) return false;
6777
- }
6778
- for (let i = baseline.params.length; i < candidate.params.length; i++) if (!candidate.params[i].optional) return false;
6779
- return true;
7351
+ /** Convert a legacy ApiParam to a CompatParameter. */
7352
+ function apiParamToCompatParam(param, position, language) {
7353
+ const policy = getDefaultPolicy(language);
7354
+ return {
7355
+ publicName: param.name,
7356
+ position,
7357
+ required: !param.optional,
7358
+ nullable: false,
7359
+ hasDefault: param.optional,
7360
+ passing: param.passingStyle ?? inferPassingStyle(language),
7361
+ type: { name: param.type },
7362
+ sensitivity: {
7363
+ order: policy.constructorOrderMatters,
7364
+ publicName: policy.methodParameterNamesArePublicApi,
7365
+ requiredness: true,
7366
+ type: true
7367
+ }
7368
+ };
6780
7369
  }
6781
- function formatSignature(method) {
6782
- return `(${method.params.map((p) => `${p.name}${p.optional ? "?" : ""}: ${p.type}`).join(", ")}) => ${method.returnType}`;
7370
+ /** Infer the default parameter passing style for a language. */
7371
+ function inferPassingStyle(language) {
7372
+ switch (language) {
7373
+ case "python":
7374
+ case "ruby":
7375
+ case "elixir": return "keyword";
7376
+ case "php":
7377
+ case "kotlin":
7378
+ case "dotnet": return "named";
7379
+ case "node": return "options_object";
7380
+ default: return "positional";
7381
+ }
6783
7382
  }
6784
7383
  //#endregion
6785
7384
  //#region src/verify/run-compat-check.ts
6786
- async function runCompatCheck(baseline, outputDir, lang, spec) {
7385
+ async function runCompatCheck(baseline, outputDir, lang, spec, policyOverrides) {
6787
7386
  const extractor = getExtractor(lang);
6788
7387
  const candidate = await extractor.extract(outputDir);
6789
7388
  let scopedBaseline = baseline;
@@ -6798,9 +7397,12 @@ async function runCompatCheck(baseline, outputDir, lang, spec) {
6798
7397
  scopedToSpec = true;
6799
7398
  scopedSymbolCount = Object.keys(scopedBaseline.interfaces).length + Object.keys(scopedBaseline.classes).length + Object.keys(scopedBaseline.typeAliases).length + Object.keys(scopedBaseline.enums).length;
6800
7399
  }
6801
- const diff = diffSurfaces(scopedBaseline, candidate, extractor.hints);
7400
+ const baseSnap = apiSurfaceToSnapshot(scopedBaseline);
7401
+ const candSnap = apiSurfaceToSnapshot(candidate);
7402
+ const langId = lang;
7403
+ const diff = diffSnapshots(baseSnap, candSnap, policyOverrides ? mergePolicy(getDefaultPolicy(langId), policyOverrides) : getDefaultPolicy(langId));
6802
7404
  return {
6803
- passed: diff.violations.every((v) => v.severity !== "breaking"),
7405
+ passed: diff.changes.every((c) => c.severity !== "breaking"),
6804
7406
  diff,
6805
7407
  scopedToSpec,
6806
7408
  scopedSymbolCount
@@ -6808,7 +7410,6 @@ async function runCompatCheck(baseline, outputDir, lang, spec) {
6808
7410
  }
6809
7411
  //#endregion
6810
7412
  //#region src/verify/run-overlay-retry-loop.ts
6811
- const PATCHABLE_CATEGORIES = new Set(["public-api", "export-structure"]);
6812
7413
  /** Generated file header markers used by various emitters. */
6813
7414
  const GENERATED_MARKERS = [
6814
7415
  "Code generated by oagen",
@@ -6900,21 +7501,21 @@ async function runOverlayRetryLoop(opts) {
6900
7501
  patchedPerIteration,
6901
7502
  compatResult
6902
7503
  };
6903
- const patchable = compatResult.diff.violations.filter((v) => PATCHABLE_CATEGORIES.has(v.category));
7504
+ const patchable = compatResult.diff.changes.filter(isPatchableChange);
6904
7505
  if (patchable.length === 0) return {
6905
7506
  status: "no-patchable",
6906
7507
  attempts: attempt,
6907
7508
  patchedPerIteration,
6908
7509
  compatResult
6909
7510
  };
6910
- const currentPreserved = compatResult.diff.preservedSymbols;
6911
- if (attempt > 0 && currentPreserved <= prevPreserved) return {
7511
+ const currentBreaking = compatResult.diff.summary.breaking;
7512
+ if (attempt > 0 && currentBreaking >= prevPreserved) return {
6912
7513
  status: "stalled",
6913
7514
  attempts: attempt,
6914
7515
  patchedPerIteration,
6915
7516
  compatResult
6916
7517
  };
6917
- prevPreserved = currentPreserved;
7518
+ prevPreserved = currentBreaking;
6918
7519
  patchedPerIteration.push(patchable.length);
6919
7520
  onRetry?.(attempt + 1, maxRetries, patchable.length);
6920
7521
  overlay = patchOverlay(overlay, patchable, baseline);
@@ -7103,17 +7704,20 @@ function runSmokeCheck(opts) {
7103
7704
  function summarizeCompatCheck(result) {
7104
7705
  const violationsByCategory = {};
7105
7706
  const violationsBySeverity = {};
7106
- for (const v of result.diff.violations) {
7107
- violationsByCategory[v.category] = (violationsByCategory[v.category] ?? 0) + 1;
7108
- violationsBySeverity[v.severity] = (violationsBySeverity[v.severity] ?? 0) + 1;
7707
+ for (const c of result.diff.changes) {
7708
+ violationsByCategory[c.category] = (violationsByCategory[c.category] ?? 0) + 1;
7709
+ violationsBySeverity[c.severity] = (violationsBySeverity[c.severity] ?? 0) + 1;
7109
7710
  }
7711
+ const { breaking, softRisk, additive } = result.diff.summary;
7712
+ const totalBaselineSymbols = breaking + softRisk + additive + additive;
7713
+ const preservedSymbols = totalBaselineSymbols - breaking - softRisk;
7110
7714
  return {
7111
- totalBaselineSymbols: result.diff.totalBaselineSymbols,
7112
- preservedSymbols: result.diff.preservedSymbols,
7113
- preservationScore: result.diff.preservationScore,
7715
+ totalBaselineSymbols,
7716
+ preservedSymbols,
7717
+ preservationScore: totalBaselineSymbols > 0 ? Math.round(preservedSymbols / totalBaselineSymbols * 100) : 100,
7114
7718
  violationsByCategory,
7115
7719
  violationsBySeverity,
7116
- additions: result.diff.additions.length,
7720
+ additions: additive,
7117
7721
  scopedToSpec: result.scopedToSpec,
7118
7722
  ...result.scopedSymbolCount !== void 0 ? { scopedSymbolCount: result.scopedSymbolCount } : {}
7119
7723
  };
@@ -7140,9 +7744,12 @@ function printCompatResult(result, baseline) {
7140
7744
  console.log(`compat: ${result.preservationScore}% (${result.preservedSymbols}/${result.totalBaselineSymbols} symbols preserved)`);
7141
7745
  }
7142
7746
  async function verifyCommand(opts) {
7143
- const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform } = opts;
7747
+ const { spec, oldSpec, lang, output, apiSurface, rawResults, smokeConfig, smokeRunner, scope, diagnostics, operationIdTransform, schemaNameTransform, compatConfig, compatReport, compatExplain } = opts;
7144
7748
  const maxRetries = opts.maxRetries ?? 3;
7145
7749
  const diagData = {};
7750
+ const effectiveFailOn = opts.compatFailOn ?? compatConfig?.failOn ?? "breaking";
7751
+ const effectiveReportPath = compatReport ?? compatConfig?.reportPath;
7752
+ const effectiveExplain = compatExplain ?? compatConfig?.explain ?? false;
7146
7753
  let stepNum = 1;
7147
7754
  const baseline = apiSurface ? JSON.parse(readFileSync(apiSurface, "utf-8")) : void 0;
7148
7755
  if (apiSurface && baseline) {
@@ -7174,11 +7781,13 @@ async function verifyCommand(opts) {
7174
7781
  };
7175
7782
  const compatSummary = summarizeCompatCheck(compatFlow.compatResult);
7176
7783
  printCompatResult(compatSummary, baseline);
7177
- for (const v of compatFlow.compatResult.diff.violations) console.log(` [${v.category}] ${v.severity}: ${v.symbolPath} — ${v.message}`);
7178
- if (compatFlow.compatResult.diff.additions.length > 0) console.log(` + ${compatFlow.compatResult.diff.additions.length} new symbols added`);
7784
+ const classifiedDiff = compatFlow.compatResult.diff;
7785
+ for (const c of classifiedDiff.changes) if (c.severity !== "additive") console.log(` [${c.category}] ${c.severity}: ${c.symbol} — ${c.message}`);
7786
+ const additiveCount = classifiedDiff.summary.additive;
7787
+ if (additiveCount > 0) console.log(` + ${additiveCount} new symbols added`);
7179
7788
  if (diagnostics) {
7180
7789
  diagData.compatCheck = compatSummary;
7181
- if (parsedSpec && maxRetries > 0) setRetryDiagnostics(diagData, compatFlow.attempts, compatFlow.status === "passed", compatFlow.compatResult.diff.preservationScore, compatFlow.patchedPerIteration);
7790
+ if (parsedSpec && maxRetries > 0) setRetryDiagnostics(diagData, compatFlow.attempts, compatFlow.status === "passed", compatSummary.preservationScore, compatFlow.patchedPerIteration);
7182
7791
  }
7183
7792
  if (compatFlow.status === "passed" && compatFlow.compatResult.passed) if (compatFlow.attempts > 0) console.log(`Compat: converged after ${compatFlow.attempts} retry iteration(s)`);
7184
7793
  else console.log("Compat: passed");
@@ -7188,9 +7797,28 @@ async function verifyCommand(opts) {
7188
7797
  console.log("Diagnostics written to verify-diagnostics.json");
7189
7798
  }
7190
7799
  if (compatFlow.status === "no-patchable") console.log("No patchable violations — cannot self-correct. Remaining violations require emitter code changes.");
7191
- else if (compatFlow.status === "stalled") console.log(`Stalled at ${compatFlow.compatResult.diff.preservationScore}% — overlay patching is not making progress.`);
7800
+ else if (compatFlow.status === "stalled") console.log(`Stalled — overlay patching is not making progress.`);
7192
7801
  throw new CommandError("\nCompat violations found — fix the emitter and re-run `oagen verify`.", "", 1);
7193
7802
  }
7803
+ if (effectiveReportPath || effectiveExplain || (compatConfig?.allow?.length ?? 0) > 0) {
7804
+ const langId = lang;
7805
+ const approvals = compatConfig?.allow ?? [];
7806
+ const remaining = unapprovedChanges(classifiedDiff.changes, approvals, langId);
7807
+ const hasFailure = remaining.some((c) => severityMeetsThreshold(c.severity, effectiveFailOn));
7808
+ if (effectiveExplain) {
7809
+ console.log("");
7810
+ console.log(formatHumanSummary(classifiedDiff, { explain: true }));
7811
+ }
7812
+ if (effectiveReportPath) {
7813
+ const report = generateReport(classifiedDiff);
7814
+ writeFileSync(effectiveReportPath, JSON.stringify(report, null, 2) + "\n");
7815
+ console.log(`Compat report written to ${effectiveReportPath}`);
7816
+ }
7817
+ if (hasFailure && effectiveFailOn !== "none") {
7818
+ const unapprovedBreaking = remaining.filter((c) => severityMeetsThreshold(c.severity, effectiveFailOn));
7819
+ console.log(`\n${unapprovedBreaking.length} unapproved change(s) at or above '${effectiveFailOn}' threshold`);
7820
+ }
7821
+ }
7194
7822
  stepNum++;
7195
7823
  }
7196
7824
  if (oldSpec && spec && apiSurface && baseline) {
@@ -7272,10 +7900,16 @@ function packageJson(lang) {
7272
7900
  type: "module",
7273
7901
  main: "dist/index.js",
7274
7902
  types: "dist/index.d.ts",
7275
- exports: { ".": {
7276
- types: "./dist/index.d.ts",
7277
- import: "./dist/index.js"
7278
- } },
7903
+ exports: {
7904
+ ".": {
7905
+ types: "./dist/index.d.ts",
7906
+ import: "./dist/index.js"
7907
+ },
7908
+ "./plugin": {
7909
+ types: "./dist/plugin.d.ts",
7910
+ import: "./dist/plugin.js"
7911
+ }
7912
+ },
7279
7913
  scripts: {
7280
7914
  build: "tsup",
7281
7915
  test: "vitest run",
@@ -7324,7 +7958,7 @@ export default defineConfig({
7324
7958
  function tsupConfig() {
7325
7959
  return `import { defineConfig } from 'tsup';
7326
7960
  export default defineConfig({
7327
- entry: { index: 'src/index.ts' },
7961
+ entry: { index: 'src/index.ts', plugin: 'src/plugin.ts' },
7328
7962
  format: ['esm'],
7329
7963
  dts: true,
7330
7964
  clean: true,
@@ -7332,19 +7966,34 @@ export default defineConfig({
7332
7966
  });
7333
7967
  `;
7334
7968
  }
7335
- function oagenConfig(lang) {
7336
- const varName = `${toCamelCase(lang)}Emitter`;
7969
+ function oagenConfig() {
7337
7970
  return `import type { OagenConfig } from '@workos/oagen';
7338
- import { ${varName} } from './src/${lang}/index.js';
7971
+ import { plugin } from './src/plugin.js';
7339
7972
 
7973
+ // Minimal config for local emitter development.
7974
+ // The canonical consumer config lives in the consumer project's oagen.config.ts,
7975
+ // which imports the plugin bundle from this package.
7340
7976
  const config: OagenConfig = {
7341
- emitters: [${varName}],
7977
+ ...plugin,
7342
7978
  };
7343
7979
  export default config;
7344
7980
  `;
7345
7981
  }
7982
+ function srcPlugin(lang) {
7983
+ const varName = `${toCamelCase(lang)}Emitter`;
7984
+ return `import type { OagenConfig } from '@workos/oagen';
7985
+ import { ${varName} } from './${lang}/index.js';
7986
+
7987
+ export const plugin: Pick<OagenConfig, 'emitters' | 'extractors' | 'smokeRunners'> = {
7988
+ emitters: [${varName}],
7989
+ extractors: [],
7990
+ smokeRunners: {},
7991
+ };
7992
+ `;
7993
+ }
7346
7994
  function srcIndex(lang) {
7347
7995
  return `export { ${`${toCamelCase(lang)}Emitter`} } from './${lang}/index.js';
7996
+ export { plugin } from './plugin.js';
7348
7997
  `;
7349
7998
  }
7350
7999
  function gitignore() {
@@ -7417,7 +8066,8 @@ async function initCommand(opts) {
7417
8066
  ["tsconfig.json", tsconfigJson()],
7418
8067
  ["vitest.config.ts", vitestConfig()],
7419
8068
  ["tsup.config.ts", tsupConfig()],
7420
- ["oagen.config.ts", oagenConfig(lang)],
8069
+ ["oagen.config.ts", oagenConfig()],
8070
+ ["src/plugin.ts", srcPlugin(lang)],
7421
8071
  ["src/index.ts", srcIndex(lang)],
7422
8072
  [`src/${lang}/index.ts`, stubEmitter(lang)]
7423
8073
  ];
@@ -7526,12 +8176,30 @@ const CONFIG_NAMES = [
7526
8176
  "oagen.config.js",
7527
8177
  "oagen.config.mjs"
7528
8178
  ];
7529
- async function loadConfig(cwd = process.cwd()) {
8179
+ /**
8180
+ * Load an oagen config file.
8181
+ *
8182
+ * @param configPath - Explicit path to a config file. When provided, only that
8183
+ * file is attempted (no fallback search).
8184
+ * @param cwd - Directory to search when `configPath` is omitted. Defaults to
8185
+ * `process.cwd()`. The loader tries each name in `CONFIG_NAMES` in order.
8186
+ */
8187
+ async function loadConfig(configPath, cwd = process.cwd()) {
8188
+ if (configPath) {
8189
+ const resolved = path$1.resolve(cwd, configPath);
8190
+ if (!existsSync(resolved)) throw new ConfigLoadError(`Config file not found: ${resolved}`, "Check that the path passed to --config exists.");
8191
+ try {
8192
+ const mod = await import(pathToFileURL(resolved).href);
8193
+ return mod.default ?? mod;
8194
+ } catch (err) {
8195
+ throw new ConfigLoadError(`Failed to load ${resolved}: ${err instanceof Error ? err.message : String(err)}`, resolved.endsWith(".ts") ? "TypeScript config files require tsx or ts-node. Use .mjs instead, or run via `npx tsx`." : "Check that the config file is valid ESM.");
8196
+ }
8197
+ }
7530
8198
  for (const name of CONFIG_NAMES) {
7531
- const configPath = path$1.resolve(cwd, name);
7532
- if (!existsSync(configPath)) continue;
8199
+ const resolved = path$1.resolve(cwd, name);
8200
+ if (!existsSync(resolved)) continue;
7533
8201
  try {
7534
- const mod = await import(pathToFileURL(configPath).href);
8202
+ const mod = await import(pathToFileURL(resolved).href);
7535
8203
  return mod.default ?? mod;
7536
8204
  } catch (err) {
7537
8205
  throw new ConfigLoadError(`Failed to load ${name}: ${err instanceof Error ? err.message : String(err)}`, name.endsWith(".ts") ? "TypeScript config files require tsx or ts-node. Use .mjs instead, or run via `npx tsx`." : "Check that the config file is valid ESM.");
@@ -7553,14 +8221,17 @@ function handleError(err) {
7553
8221
  if (message) console.error(message);
7554
8222
  process.exit(exitCode);
7555
8223
  }
8224
+ const configArgIdx = process.argv.indexOf("--config");
8225
+ const explicitConfigPath = configArgIdx !== -1 ? process.argv[configArgIdx + 1] : void 0;
7556
8226
  let configSmokeRunners;
7557
8227
  let configOperationIdTransform;
7558
8228
  let configSchemaNameTransform;
7559
8229
  let configDocUrl;
7560
8230
  let configOperationHints;
7561
8231
  let configMountRules;
8232
+ let configCompat;
7562
8233
  try {
7563
- const config = await loadConfig();
8234
+ const config = await loadConfig(explicitConfigPath);
7564
8235
  if (config) {
7565
8236
  applyConfig(config);
7566
8237
  configSmokeRunners = config.smokeRunners;
@@ -7569,11 +8240,12 @@ try {
7569
8240
  configDocUrl = config.docUrl;
7570
8241
  configOperationHints = config.operationHints;
7571
8242
  configMountRules = config.mountRules;
8243
+ configCompat = config.compat;
7572
8244
  }
7573
8245
  } catch (err) {
7574
8246
  handleError(err);
7575
8247
  }
7576
- const program = new Command().name("oagen").description("Framework for building OpenAPI SDK emitters").version("0.0.1");
8248
+ const program = new Command().name("oagen").description("Framework for building OpenAPI SDK emitters").version("0.0.1").option("--config <path>", "Path to oagen config file (default: oagen.config.ts in cwd)");
7577
8249
  program.command("parse").description("Parse an OpenAPI spec and output IR as JSON").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").action((opts) => {
7578
8250
  opts.spec ??= process.env.OPENAPI_SPEC_PATH;
7579
8251
  if (!opts.spec) {
@@ -7609,14 +8281,29 @@ program.command("extract").description("Extract public API surface from a live S
7609
8281
  opts.output ??= `sdk-${opts.lang}-surface.json`;
7610
8282
  extractCommand(opts).catch(handleError);
7611
8283
  });
7612
- program.command("verify").description("Run smoke tests (and optional compat check) against an already-generated SDK").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Path to the generated SDK").option("--api-surface <path>", "Baseline API surface JSON — enables compat verification").option("--raw-results <path>", "Path to an existing smoke baseline file to diff against").option("--smoke-config <path>", "Path to smoke config JSON for skip lists and service mappings").option("--smoke-runner <path>", "Path to a custom smoke runner script (overrides built-in sdk-test.ts)").option("--scope <mode>", "Compat scope: \"full\" compares all baseline symbols, \"spec-only\" compares only symbols derivable from the OpenAPI spec (default: spec-only when --spec is provided)").option("--old-spec <path>", "Previous OpenAPI spec — enables staleness detection for removed/renamed symbols").option("--namespace <name>", "SDK namespace/package name (used by retry loop for regeneration)").option("--diagnostics", "Output verify-diagnostics.json with structured violation breakdown").option("--max-retries <n>", "Max retry iterations for self-correcting overlay loop (default: 3)", "3").action((opts) => {
8284
+ program.command("compat-extract").description("Extract a compat snapshot from a live SDK and write .oagen-compat-snapshot.json").requiredOption("--sdk-path <path>", "Path to the live SDK").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Directory to write .oagen-compat-snapshot.json into").option("--spec <path>", "Path to OpenAPI spec — enriches symbols with operationId, route, and specSha").action((opts) => {
8285
+ opts.spec ??= process.env.OPENAPI_SPEC_PATH;
8286
+ compatExtractCommand(opts).catch(handleError);
8287
+ });
8288
+ 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) => {
8289
+ compatDiffCommand(opts).catch(handleError);
8290
+ });
8291
+ program.command("compat-summary").description("Format compat report(s) as a markdown PR comment").requiredOption("--report <path...>", "Path(s) to compat report JSON(s) — pass multiple for cross-language rollup").option("--output <path>", "Write markdown to this file instead of stdout").action((opts) => {
8292
+ compatSummaryCommand(opts).catch(handleError);
8293
+ });
8294
+ program.command("verify").description("Run smoke tests (and optional compat check) against an already-generated SDK").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").requiredOption("--lang <language>", "Target language").requiredOption("--output <dir>", "Path to the generated SDK").option("--api-surface <path>", "Baseline API surface JSON — enables compat verification").option("--raw-results <path>", "Path to an existing smoke baseline file to diff against").option("--smoke-config <path>", "Path to smoke config JSON for skip lists and service mappings").option("--smoke-runner <path>", "Path to a custom smoke runner script (overrides built-in sdk-test.ts)").option("--scope <mode>", "Compat scope: \"full\" compares all baseline symbols, \"spec-only\" compares only symbols derivable from the OpenAPI spec (default: spec-only when --spec is provided)").option("--old-spec <path>", "Previous OpenAPI spec — enables staleness detection for removed/renamed symbols").option("--namespace <name>", "SDK namespace/package name (used by retry loop for regeneration)").option("--diagnostics", "Output verify-diagnostics.json with structured violation breakdown").option("--max-retries <n>", "Max retry iterations for self-correcting overlay loop (default: 3)", "3").option("--compat-report <path>", "Write machine-readable compat report to this path").option("--compat-fail-on <level>", "Fail threshold: none, breaking, or soft-risk").option("--compat-baseline <path>", "Path to baseline compatibility snapshot").option("--compat-explain", "Include provenance explanations in compat output").action((opts) => {
7613
8295
  opts.spec ??= process.env.OPENAPI_SPEC_PATH;
7614
8296
  opts.smokeRunner ??= configSmokeRunners?.[opts.lang];
7615
8297
  verifyCommand({
7616
8298
  ...opts,
7617
8299
  maxRetries: parseInt(opts.maxRetries, 10),
7618
8300
  operationIdTransform: configOperationIdTransform,
7619
- schemaNameTransform: configSchemaNameTransform
8301
+ schemaNameTransform: configSchemaNameTransform,
8302
+ compatConfig: configCompat,
8303
+ compatReport: opts.compatReport,
8304
+ compatFailOn: opts.compatFailOn,
8305
+ compatBaseline: opts.compatBaseline,
8306
+ compatExplain: opts.compatExplain
7620
8307
  }).catch(handleError);
7621
8308
  });
7622
8309
  program.command("resolve").description("Resolve operation names from spec and output a review table").option("--spec <path>", "Path to OpenAPI spec file (or set OPENAPI_SPEC_PATH)").option("--format <format>", "Output format: table or json", "table").action((opts) => {