@workos/oagen 0.12.0 → 0.14.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.
@@ -912,12 +912,269 @@ function diffSnapshots(baseline, candidate, policy) {
912
912
  if (isServiceWrapperConstructor(candSym, serviceAccessors)) continue;
913
913
  changes.push(classifyAddedSymbol(candSym));
914
914
  }
915
+ detectForkedSchemas(changes, baseline, candidate);
916
+ cascadeRenameDowngrades(changes, detectTypeRenames(changes, baseline, candidate), detectEnumRenames(changes, baseline, candidate));
915
917
  return {
916
918
  changes,
917
919
  summary: summarizeChanges(changes)
918
920
  };
919
921
  }
920
922
  /**
923
+ * Detect "schema fork" antipattern and attach a remediation hint.
924
+ *
925
+ * Fires when:
926
+ * 1. A return type or field type changed from `OldType` → `NewType`.
927
+ * 2. `NewType` is newly added in the candidate snapshot.
928
+ * 3. `NewType.fields ⊇ OldType.fields` (every field on the old type still
929
+ * exists on the new one — the new schema is a strict superset, so the
930
+ * same fields could have been added to the existing schema additively).
931
+ *
932
+ * Mutates `changes` in place: sets `remediation` on the matching change.
933
+ * Leaves the original category and severity alone — this is just a hint.
934
+ *
935
+ * Identity match is by `displayName` of the type symbol, which corresponds
936
+ * to the type name as it appears in `change.old.returnType` / `.type` and
937
+ * `change.new.returnType` / `.type`.
938
+ */
939
+ function detectForkedSchemas(changes, baseline, candidate) {
940
+ const baselineTypeFields = collectTypeFieldSets(baseline);
941
+ const candidateTypeFields = collectTypeFieldSets(candidate);
942
+ const baselineTypeNames = new Set(baselineTypeFields.keys());
943
+ for (const change of changes) {
944
+ if (change.remediation) continue;
945
+ let oldType;
946
+ let newType;
947
+ if (change.category === "return_type_changed") {
948
+ oldType = change.old.returnType;
949
+ newType = change.new.returnType;
950
+ } else if (change.category === "field_type_changed") {
951
+ oldType = change.old.type;
952
+ newType = change.new.type;
953
+ } else continue;
954
+ if (!oldType || !newType || oldType === newType) continue;
955
+ const oldTypeBare = bareTypeName(oldType);
956
+ const newTypeBare = bareTypeName(newType);
957
+ if (baselineTypeNames.has(newTypeBare)) continue;
958
+ const oldFields = baselineTypeFields.get(oldTypeBare);
959
+ const newFields = candidateTypeFields.get(newTypeBare);
960
+ if (!oldFields || !newFields) continue;
961
+ if (oldFields.size === 0) continue;
962
+ let isSuperset = true;
963
+ for (const f of oldFields) if (!newFields.has(f)) {
964
+ isSuperset = false;
965
+ break;
966
+ }
967
+ if (!isSuperset) continue;
968
+ 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.`;
969
+ }
970
+ }
971
+ /**
972
+ * Build a map from type fqName → set of (lowercased) child field names.
973
+ * Used by `detectForkedSchemas` for superset comparison.
974
+ */
975
+ function collectTypeFieldSets(snapshot) {
976
+ const result = /* @__PURE__ */ new Map();
977
+ for (const sym of snapshot.symbols) {
978
+ if (sym.kind !== "field" && sym.kind !== "property") continue;
979
+ if (!sym.ownerFqName) continue;
980
+ const localName = sym.fqName.includes(".") ? sym.fqName.slice(sym.fqName.lastIndexOf(".") + 1) : sym.fqName;
981
+ let set = result.get(sym.ownerFqName);
982
+ if (!set) {
983
+ set = /* @__PURE__ */ new Set();
984
+ result.set(sym.ownerFqName, set);
985
+ }
986
+ set.add(localName.toLowerCase());
987
+ }
988
+ return result;
989
+ }
990
+ /**
991
+ * Detect type renames: a baseline type symbol disappears and a candidate
992
+ * type symbol with the same (or superset) field set takes its place.
993
+ *
994
+ * Common when an upstream spec promotes a single schema into multiple
995
+ * (e.g. `ApiKey` → `OrganizationApiKey` + `UserApiKey`). The wire shape
996
+ * returned by individual endpoints is unchanged — `OrganizationApiKey`
997
+ * has the same fields `ApiKey` had — so consumer code accessing those
998
+ * fields keeps working. The compat report flags `ApiKey` as removed
999
+ * because its symbol is gone; this pass downgrades the removal to
1000
+ * soft-risk and records the rename so its child fields/methods cascade.
1001
+ *
1002
+ * Identity criteria (must all hold):
1003
+ * 1. The removed symbol owns ≥ 1 field/property in the baseline (i.e.
1004
+ * it's a type-shaped symbol — model/interface/class — not a plain
1005
+ * function or constant).
1006
+ * 2. Some candidate type that did *not* exist in the baseline has a
1007
+ * field set that is a non-strict superset of the removed type's
1008
+ * fields. (Strict-subset would mean fields were lost — a real break.)
1009
+ * 3. The candidate type is the alphabetically-first such match, so
1010
+ * pairing is deterministic when multiple candidates fit (e.g. both
1011
+ * `OrganizationApiKey` and `UserApiKey` share the original fields).
1012
+ *
1013
+ * Returns a `removedName -> newName` map so a downstream cascade pass
1014
+ * can downgrade owned-field removals and `*_type_changed` pointing at
1015
+ * the same pair.
1016
+ *
1017
+ * Mutates matching `symbol_removed` entries in `changes`: severity →
1018
+ * `soft-risk`, attaches a `remediation` describing the rename.
1019
+ */
1020
+ function detectTypeRenames(changes, baseline, candidate) {
1021
+ const renameMap = /* @__PURE__ */ new Map();
1022
+ const baselineTypeFields = collectTypeFieldSets(baseline);
1023
+ const candidateTypeFields = collectTypeFieldSets(candidate);
1024
+ const baselineTypeNames = new Set(baselineTypeFields.keys());
1025
+ const candidateTypesSorted = [...candidateTypeFields.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1026
+ for (const change of changes) {
1027
+ if (change.category !== "symbol_removed") continue;
1028
+ if (change.severity !== "breaking") continue;
1029
+ const removedName = bareTypeName(change.old.symbol ?? "");
1030
+ if (!removedName) continue;
1031
+ const removedFields = baselineTypeFields.get(removedName);
1032
+ if (!removedFields || removedFields.size === 0) continue;
1033
+ const match = candidateTypesSorted.find(([candName, candFields]) => {
1034
+ if (baselineTypeNames.has(candName)) return false;
1035
+ for (const f of removedFields) if (!candFields.has(f)) return false;
1036
+ return true;
1037
+ });
1038
+ if (!match) continue;
1039
+ const [newName] = match;
1040
+ renameMap.set(removedName, newName);
1041
+ change.severity = "soft-risk";
1042
+ change.remediation = `Type "${removedName}" appears to have been renamed to "${newName}" — the new type has every field of the old (a non-strict superset). Field accesses and method calls on values of type "${newName}" continue to work; only explicit "${removedName}" type annotations need to migrate. Consider emitting a deprecated alias \`type ${removedName} = ${newName}\` in languages that support it.`;
1043
+ }
1044
+ return renameMap;
1045
+ }
1046
+ /**
1047
+ * Detect enum canonical-flips: a baseline enum disappears and a candidate
1048
+ * enum with the **same wire-value set** takes its place.
1049
+ *
1050
+ * Caused by the emitter's enum-dedup heuristic picking a different
1051
+ * canonical name when a new same-shape enum joins the spec. Languages
1052
+ * that emit type aliases (Go, Ruby, Python, PHP, Kotlin) handle this
1053
+ * transparently via `type Old = New`; languages without first-class
1054
+ * aliases (dotnet) report the old enum as removed. The wire values are
1055
+ * unchanged — every legal value still serializes to the same JSON — so
1056
+ * consumer code constructing or matching on these enum values keeps
1057
+ * working. Only references to the typed enum class need migration.
1058
+ *
1059
+ * Identity criterion: a removed enum's value set is *exactly* equal to
1060
+ * a newly-added enum's value set (not superset — narrowing the value
1061
+ * set would be a real break for consumers expecting the dropped values).
1062
+ */
1063
+ function detectEnumRenames(changes, baseline, candidate) {
1064
+ const renameMap = /* @__PURE__ */ new Map();
1065
+ const baselineEnumValues = collectEnumValueSets(baseline);
1066
+ const candidateEnumValues = collectEnumValueSets(candidate);
1067
+ const baselineEnumNames = new Set(baselineEnumValues.keys());
1068
+ const candidateEnumsSorted = [...candidateEnumValues.entries()].sort((a, b) => a[0].localeCompare(b[0]));
1069
+ for (const change of changes) {
1070
+ if (change.category !== "symbol_removed") continue;
1071
+ if (change.severity !== "breaking") continue;
1072
+ const removedName = bareTypeName(change.old.symbol ?? "");
1073
+ if (!removedName) continue;
1074
+ const removedValues = baselineEnumValues.get(removedName);
1075
+ if (!removedValues || removedValues.size === 0) continue;
1076
+ const match = candidateEnumsSorted.find(([candName, candValues]) => {
1077
+ if (baselineEnumNames.has(candName)) return false;
1078
+ if (candValues.size !== removedValues.size) return false;
1079
+ for (const v of removedValues) if (!candValues.has(v)) return false;
1080
+ return true;
1081
+ });
1082
+ if (!match) continue;
1083
+ const [newName] = match;
1084
+ renameMap.set(removedName, newName);
1085
+ change.severity = "soft-risk";
1086
+ change.remediation = `Enum "${removedName}" appears to have been renamed to "${newName}" — both enums have identical wire values, so on-the-wire serialization is unchanged. This is typically the emitter's dedup canonical-flip after a new same-shape enum joined the spec. Consider emitting a deprecated alias in languages that support it, or pinning the canonical via emitter config.`;
1087
+ }
1088
+ return renameMap;
1089
+ }
1090
+ /**
1091
+ * Cascade rename downgrades to changes whose meaning depends on a renamed
1092
+ * symbol. Walks every change and:
1093
+ *
1094
+ * - Downgrades child removals (`Owner.field` removed where `Owner` was
1095
+ * renamed) — the field still exists, just under a new owner fqName.
1096
+ * Same logic for enum members under a renamed enum.
1097
+ * - Downgrades `return_type_changed` / `field_type_changed` whose
1098
+ * old → new pair matches a recorded rename — the type swap is the
1099
+ * rename itself, not a meaningful signature break.
1100
+ *
1101
+ * Each cascaded change gets a remediation pointing at the parent rename
1102
+ * so the reviewer can find the explanation in the report.
1103
+ */
1104
+ function cascadeRenameDowngrades(changes, typeRenames, enumRenames) {
1105
+ if (typeRenames.size === 0 && enumRenames.size === 0) return;
1106
+ for (const change of changes) {
1107
+ if (change.severity !== "breaking") continue;
1108
+ if (change.category === "symbol_removed") {
1109
+ const removed = change.old.symbol ?? "";
1110
+ const dotIdx = removed.indexOf(".");
1111
+ if (dotIdx <= 0) continue;
1112
+ const ownerName = removed.slice(0, dotIdx);
1113
+ const renamedTo = typeRenames.get(ownerName) ?? enumRenames.get(ownerName);
1114
+ if (!renamedTo) continue;
1115
+ change.severity = "soft-risk";
1116
+ change.remediation = `Owned by renamed symbol "${ownerName}" (now "${renamedTo}"). The same member exists on the new symbol under "${renamedTo}.${removed.slice(dotIdx + 1)}".`;
1117
+ continue;
1118
+ }
1119
+ if (change.category === "return_type_changed") {
1120
+ const oldT = bareTypeName(change.old.returnType ?? "");
1121
+ const newT = bareTypeName(change.new.returnType ?? "");
1122
+ if (typeRenames.get(oldT) === newT) {
1123
+ change.severity = "soft-risk";
1124
+ change.remediation = `Return type swap matches recorded rename "${oldT}" → "${newT}". The underlying field set is preserved (see the rename advisory on "${oldT}").`;
1125
+ }
1126
+ continue;
1127
+ }
1128
+ if (change.category === "field_type_changed") {
1129
+ const oldT = bareTypeName(change.old.type ?? "");
1130
+ const newT = bareTypeName(change.new.type ?? "");
1131
+ if ((typeRenames.get(oldT) ?? enumRenames.get(oldT)) === newT) {
1132
+ change.severity = "soft-risk";
1133
+ change.remediation = `Field type swap matches recorded rename "${oldT}" → "${newT}". On-the-wire shape is unchanged (see the rename advisory on "${oldT}").`;
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+ /**
1139
+ * Build a map from enum fqName → set of wire values. Used by
1140
+ * `detectEnumRenames` to find structurally-identical enums across
1141
+ * baseline and candidate. Wire values come from `enum_member.value`
1142
+ * (the JSON-level value) — not the member names, which are
1143
+ * language-specific PascalCase forms.
1144
+ *
1145
+ * Members whose `value` is undefined are skipped — they contribute no
1146
+ * identity information.
1147
+ */
1148
+ function collectEnumValueSets(snapshot) {
1149
+ const result = /* @__PURE__ */ new Map();
1150
+ for (const sym of snapshot.symbols) {
1151
+ if (sym.kind !== "enum_member") continue;
1152
+ if (!sym.ownerFqName) continue;
1153
+ if (sym.value === void 0) continue;
1154
+ let set = result.get(sym.ownerFqName);
1155
+ if (!set) {
1156
+ set = /* @__PURE__ */ new Set();
1157
+ result.set(sym.ownerFqName, set);
1158
+ }
1159
+ set.add(String(sym.value));
1160
+ }
1161
+ return result;
1162
+ }
1163
+ /**
1164
+ * Strip array/nullable suffixes so we compare bare type names. Languages
1165
+ * encode these differently (`Foo[]`, `Foo | null`, `Foo?`, `List<Foo>`,
1166
+ * `Optional[Foo]`); this is a best-effort common-case stripper, not an
1167
+ * exhaustive parser.
1168
+ */
1169
+ function bareTypeName(t) {
1170
+ let s = t.trim();
1171
+ s = s.replace(/\s*\|\s*null$/, "").replace(/\?$/, "");
1172
+ s = s.replace(/\[\]$/, "");
1173
+ const generic = s.match(/^[A-Za-z_][A-Za-z0-9_.]*<\s*([A-Za-z_][A-Za-z0-9_.]*)\s*>$/);
1174
+ if (generic) s = generic[1];
1175
+ return s;
1176
+ }
1177
+ /**
921
1178
  * Check if a symbol is a constructor belonging to a service wrapper class.
922
1179
  *
923
1180
  * Service wrapper constructors are internal plumbing (taking a client/config
@@ -1711,4 +1968,4 @@ function patchOverlay(overlay, changes, baseline) {
1711
1968
  //#endregion
1712
1969
  export { getDefaultPolicy as A, defaultSeverityForCategory as C, isCompatibleSchemaVersion as D, COMPAT_SCHEMA_VERSION as E, validateSnapshot as O, summarizeChanges as S, apiSurfaceToSnapshot as T, nodeHints as _, registerExtractor as a, classifyAddedSymbol as b, filterSurface as c, specDerivedHttpKeys as d, specDerivedMethodPaths as f, namedTypeWordsOverlap as g, defaultIsNullableOnlyDifference as h, getExtractor as i, mergePolicy as j, ALL_LANGUAGE_IDS as k, specDerivedEnumValues as l, NAMED_TYPE_RE as m, isPatchableChange as n, diffSnapshots as o, specDerivedNames as p, patchOverlay as r, diffSurfaces as s, buildOverlayLookup as t, specDerivedFieldPaths as u, resolveHints as v, severityMeetsThreshold as w, classifySymbolChanges as x, typeExistsInSurface as y };
1713
1970
 
1714
- //# sourceMappingURL=overlay-DTaApIqN.mjs.map
1971
+ //# sourceMappingURL=overlay-DGcHh1KN.mjs.map