From b675848542024860b82d0c777ee7c52259125979 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Sat, 2 May 2026 10:40:43 -0400 Subject: [PATCH] feat(compat): detect type and enum renames as soft-risk, not breaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a baseline symbol disappears and a structurally-equivalent symbol takes its place in the candidate, the public-API surface is preserved at the value level — only typed references and (in some languages) enum class names actually need to migrate. The current differ flags these as `symbol_removed` (breaking) anyway, which produces false-positive CI gates for spec changes that are functionally additive. Two new post-passes in `diffSnapshots`, both pure downgrades (never upgrade severity, so they cannot make existing reports worse): `detectTypeRenames` — for every `symbol_removed` whose baseline owner held ≥ 1 field, look for a newly-added candidate type whose field set is a non-strict superset of the removed type's fields. On match, downgrade to soft-risk and record the rename. Pairing is alphabetical-first when several candidates fit, so the result is deterministic. `detectEnumRenames` — same shape but for enums, with strict equality on wire-value sets (narrowing or shifting an enum is a real wire break and must stay flagged). Catches the dotnet-only "old canonical enum disappeared because dedup heuristic flipped to a new shorter name" case from workos/openapi-spec#17. `cascadeRenameDowngrades` — runs after both detectors. Walks every remaining `symbol_removed` whose owner is in either rename map and downgrades it (the field/method still exists on the new owner under a different fqName). Also downgrades `return_type_changed` and `field_type_changed` whose old → new pair matches a recorded rename (the type swap is the rename itself, not a meaningful signature break). Soft-risk, not additive, because explicit type annotations on the old name still need to migrate — `var k *workos.ApiKeyWithValue = ...` does fail to compile when the type symbol is gone. Soft-risk is the existing "may affect callers depending on usage" category and is the right level. Concrete impact on workos/openapi-spec#17: - ApiKey/ApiKeyWithValue/APIKeyWithValueOwner family (~25 of 33 items): baseline types had clean superset matches in OrganizationApi*; all downgrade to soft-risk with cascade across method return types and owned fields. - ApplicationsOrder, VaultByokKeyVerificationCompletedDataKeyProvider (~6 of 33): identical wire-value enums in candidate; downgrade to soft-risk with cascade across enum members. - Net: Breaking 33 → Breaking 0; Soft-risk gains the same rows with remediation hints attached. Adds 11 unit tests in test/compat/rename-detection.test.ts mirroring the forked-schemas.test.ts conventions: covers happy-path downgrade for both types and enums, cascade to children/return types/iterator generics, deterministic alphabetical pairing, plus negative cases (real field reshape, candidate already in baseline, enum value-set mismatch, strict-superset enum addition). Existing tests untouched and still pass (1366 → 1377 with 11 new). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/compat/differ.ts | 246 +++++++++++++++ test/compat/rename-detection.test.ts | 456 +++++++++++++++++++++++++++ 2 files changed, 702 insertions(+) create mode 100644 test/compat/rename-detection.test.ts diff --git a/src/compat/differ.ts b/src/compat/differ.ts index 5b62945..9004376 100644 --- a/src/compat/differ.ts +++ b/src/compat/differ.ts @@ -93,6 +93,22 @@ export function diffSnapshots( // signature change instead of an additive field on the existing schema. detectForkedSchemas(changes, baseline, candidate); + // Post-pass: detect type and enum *renames* — cases where a baseline + // symbol disappears and a structurally-equivalent symbol takes its place + // in the candidate. These are reported as `symbol_removed` (breaking) by + // the symbol-level walker because the fqName is gone, but consumer code + // that uses the type's fields/methods or the enum's wire values continues + // to work — only explicit type annotations and (in dotnet) un-aliased + // enum class references actually need to migrate. Downgrade these to + // soft-risk so CI gates default-pass while the change stays visible. + // + // Ordered after detectForkedSchemas so the fork detector's remediation + // hint is preserved on the typed cases it owns; renames operate on + // pure removals where no fork hint applies. + const typeRenames = detectTypeRenames(changes, baseline, candidate); + const enumRenames = detectEnumRenames(changes, baseline, candidate); + cascadeRenameDowngrades(changes, typeRenames, enumRenames); + return { changes, summary: summarizeChanges(changes), @@ -190,6 +206,236 @@ function collectTypeFieldSets(snapshot: CompatSnapshot): Map return result; } +/** + * Detect type renames: a baseline type symbol disappears and a candidate + * type symbol with the same (or superset) field set takes its place. + * + * Common when an upstream spec promotes a single schema into multiple + * (e.g. `ApiKey` → `OrganizationApiKey` + `UserApiKey`). The wire shape + * returned by individual endpoints is unchanged — `OrganizationApiKey` + * has the same fields `ApiKey` had — so consumer code accessing those + * fields keeps working. The compat report flags `ApiKey` as removed + * because its symbol is gone; this pass downgrades the removal to + * soft-risk and records the rename so its child fields/methods cascade. + * + * Identity criteria (must all hold): + * 1. The removed symbol owns ≥ 1 field/property in the baseline (i.e. + * it's a type-shaped symbol — model/interface/class — not a plain + * function or constant). + * 2. Some candidate type that did *not* exist in the baseline has a + * field set that is a non-strict superset of the removed type's + * fields. (Strict-subset would mean fields were lost — a real break.) + * 3. The candidate type is the alphabetically-first such match, so + * pairing is deterministic when multiple candidates fit (e.g. both + * `OrganizationApiKey` and `UserApiKey` share the original fields). + * + * Returns a `removedName -> newName` map so a downstream cascade pass + * can downgrade owned-field removals and `*_type_changed` pointing at + * the same pair. + * + * Mutates matching `symbol_removed` entries in `changes`: severity → + * `soft-risk`, attaches a `remediation` describing the rename. + */ +function detectTypeRenames( + changes: ClassifiedChange[], + baseline: CompatSnapshot, + candidate: CompatSnapshot, +): Map { + const renameMap = new Map(); + const baselineTypeFields = collectTypeFieldSets(baseline); + const candidateTypeFields = collectTypeFieldSets(candidate); + const baselineTypeNames = new Set(baselineTypeFields.keys()); + + // Pre-sort candidate type names alphabetically for deterministic pairing + // when several candidates structurally match the same removed type. + const candidateTypesSorted = [...candidateTypeFields.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const change of changes) { + if (change.category !== 'symbol_removed') continue; + if (change.severity !== 'breaking') continue; + + const removedName = bareTypeName(change.old.symbol ?? ''); + if (!removedName) continue; + + const removedFields = baselineTypeFields.get(removedName); + if (!removedFields || removedFields.size === 0) continue; + + // First newly-added candidate whose field set ⊇ removed field set. + const match = candidateTypesSorted.find(([candName, candFields]) => { + if (baselineTypeNames.has(candName)) return false; + for (const f of removedFields) { + if (!candFields.has(f)) return false; + } + return true; + }); + if (!match) continue; + const [newName] = match; + + renameMap.set(removedName, newName); + change.severity = 'soft-risk'; + 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.`; + } + + return renameMap; +} + +/** + * Detect enum canonical-flips: a baseline enum disappears and a candidate + * enum with the **same wire-value set** takes its place. + * + * Caused by the emitter's enum-dedup heuristic picking a different + * canonical name when a new same-shape enum joins the spec. Languages + * that emit type aliases (Go, Ruby, Python, PHP, Kotlin) handle this + * transparently via `type Old = New`; languages without first-class + * aliases (dotnet) report the old enum as removed. The wire values are + * unchanged — every legal value still serializes to the same JSON — so + * consumer code constructing or matching on these enum values keeps + * working. Only references to the typed enum class need migration. + * + * Identity criterion: a removed enum's value set is *exactly* equal to + * a newly-added enum's value set (not superset — narrowing the value + * set would be a real break for consumers expecting the dropped values). + */ +function detectEnumRenames( + changes: ClassifiedChange[], + baseline: CompatSnapshot, + candidate: CompatSnapshot, +): Map { + const renameMap = new Map(); + const baselineEnumValues = collectEnumValueSets(baseline); + const candidateEnumValues = collectEnumValueSets(candidate); + const baselineEnumNames = new Set(baselineEnumValues.keys()); + + const candidateEnumsSorted = [...candidateEnumValues.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const change of changes) { + if (change.category !== 'symbol_removed') continue; + if (change.severity !== 'breaking') continue; + + const removedName = bareTypeName(change.old.symbol ?? ''); + if (!removedName) continue; + + const removedValues = baselineEnumValues.get(removedName); + if (!removedValues || removedValues.size === 0) continue; + + const match = candidateEnumsSorted.find(([candName, candValues]) => { + if (baselineEnumNames.has(candName)) return false; + if (candValues.size !== removedValues.size) return false; + for (const v of removedValues) { + if (!candValues.has(v)) return false; + } + return true; + }); + if (!match) continue; + const [newName] = match; + + renameMap.set(removedName, newName); + change.severity = 'soft-risk'; + 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.`; + } + + return renameMap; +} + +/** + * Cascade rename downgrades to changes whose meaning depends on a renamed + * symbol. Walks every change and: + * + * - Downgrades child removals (`Owner.field` removed where `Owner` was + * renamed) — the field still exists, just under a new owner fqName. + * Same logic for enum members under a renamed enum. + * - Downgrades `return_type_changed` / `field_type_changed` whose + * old → new pair matches a recorded rename — the type swap is the + * rename itself, not a meaningful signature break. + * + * Each cascaded change gets a remediation pointing at the parent rename + * so the reviewer can find the explanation in the report. + */ +function cascadeRenameDowngrades( + changes: ClassifiedChange[], + typeRenames: Map, + enumRenames: Map, +): void { + if (typeRenames.size === 0 && enumRenames.size === 0) return; + + for (const change of changes) { + if (change.severity !== 'breaking') continue; + + if (change.category === 'symbol_removed') { + const removed = change.old.symbol ?? ''; + const dotIdx = removed.indexOf('.'); + if (dotIdx <= 0) continue; + const ownerName = removed.slice(0, dotIdx); + const renamedTo = typeRenames.get(ownerName) ?? enumRenames.get(ownerName); + if (!renamedTo) continue; + change.severity = 'soft-risk'; + change.remediation = + `Owned by renamed symbol "${ownerName}" (now "${renamedTo}"). ` + + `The same member exists on the new symbol under "${renamedTo}.${removed.slice(dotIdx + 1)}".`; + continue; + } + + if (change.category === 'return_type_changed') { + const oldT = bareTypeName(change.old.returnType ?? ''); + const newT = bareTypeName(change.new.returnType ?? ''); + if (typeRenames.get(oldT) === newT) { + change.severity = 'soft-risk'; + change.remediation = + `Return type swap matches recorded rename "${oldT}" → "${newT}". ` + + `The underlying field set is preserved (see the rename advisory on "${oldT}").`; + } + continue; + } + + if (change.category === 'field_type_changed') { + const oldT = bareTypeName(change.old.type ?? ''); + const newT = bareTypeName(change.new.type ?? ''); + const renamedTo = typeRenames.get(oldT) ?? enumRenames.get(oldT); + if (renamedTo === newT) { + change.severity = 'soft-risk'; + change.remediation = + `Field type swap matches recorded rename "${oldT}" → "${newT}". ` + + `On-the-wire shape is unchanged (see the rename advisory on "${oldT}").`; + } + } + } +} + +/** + * Build a map from enum fqName → set of wire values. Used by + * `detectEnumRenames` to find structurally-identical enums across + * baseline and candidate. Wire values come from `enum_member.value` + * (the JSON-level value) — not the member names, which are + * language-specific PascalCase forms. + * + * Members whose `value` is undefined are skipped — they contribute no + * identity information. + */ +function collectEnumValueSets(snapshot: CompatSnapshot): Map> { + const result = new Map>(); + for (const sym of snapshot.symbols) { + if (sym.kind !== 'enum_member') continue; + if (!sym.ownerFqName) continue; + if (sym.value === undefined) continue; + let set = result.get(sym.ownerFqName); + if (!set) { + set = new Set(); + result.set(sym.ownerFqName, set); + } + set.add(String(sym.value)); + } + return result; +} + /** * Strip array/nullable suffixes so we compare bare type names. Languages * encode these differently (`Foo[]`, `Foo | null`, `Foo?`, `List`, diff --git a/test/compat/rename-detection.test.ts b/test/compat/rename-detection.test.ts new file mode 100644 index 0000000..425b262 --- /dev/null +++ b/test/compat/rename-detection.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect } from 'vitest'; +import { diffSnapshots } from '../../src/compat/differ.js'; +import { getDefaultPolicy } from '../../src/compat/policy.js'; +import type { CompatSnapshot, CompatSymbol } from '../../src/compat/ir.js'; + +function makeSnapshot(symbols: CompatSymbol[]): CompatSnapshot { + return { + schemaVersion: '1', + source: { extractedAt: '2026-05-02T00:00:00.000Z' }, + policies: getDefaultPolicy('go'), + symbols, + }; +} + +function sym(overrides: Partial & { fqName: string; kind: CompatSymbol['kind'] }): CompatSymbol { + return { + id: overrides.id ?? `test:${overrides.fqName}`, + displayName: overrides.displayName ?? overrides.fqName, + visibility: 'public', + stability: 'stable', + sourceKind: 'generated_resource_constructor', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Type-rename detection +// --------------------------------------------------------------------------- + +describe('detectTypeRenames — model type rename detection', () => { + /** + * Mirror the workos/openapi-spec#17 ApiKey → OrganizationApiKey case: + * the old type vanishes and a structurally-equivalent (or superset) + * new type takes its place. The wire shape returned by the underlying + * endpoint is identical, so consumer code accessing `.id`, `.value`, etc. + * keeps working — only explicit type annotations need to migrate. + */ + it('downgrades a type removal to soft-risk when a structurally-equivalent new type exists', () => { + const baseline = makeSnapshot([ + sym({ fqName: 'ApiKeyWithValue', kind: 'alias' }), + sym({ + fqName: 'ApiKeyWithValue.id', + kind: 'field', + ownerFqName: 'ApiKeyWithValue', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'ApiKeyWithValue.value', + kind: 'field', + ownerFqName: 'ApiKeyWithValue', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'ApiKeyWithValue.created_at', + kind: 'field', + ownerFqName: 'ApiKeyWithValue', + typeRef: { name: 'string' }, + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'OrganizationApiKeyWithValue', kind: 'alias' }), + sym({ + fqName: 'OrganizationApiKeyWithValue.id', + kind: 'field', + ownerFqName: 'OrganizationApiKeyWithValue', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'OrganizationApiKeyWithValue.value', + kind: 'field', + ownerFqName: 'OrganizationApiKeyWithValue', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'OrganizationApiKeyWithValue.created_at', + kind: 'field', + ownerFqName: 'OrganizationApiKeyWithValue', + typeRef: { name: 'string' }, + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const removal = result.changes.find((c) => c.category === 'symbol_removed' && c.old.symbol === 'ApiKeyWithValue'); + expect(removal, 'parent type removal change should exist').toBeDefined(); + expect(removal!.severity).toBe('soft-risk'); + expect(removal!.remediation).toMatch(/renamed to "OrganizationApiKeyWithValue"/); + expect(removal!.remediation).toMatch(/superset/); + }); + + it('cascades the downgrade to owned-field removals so they are also soft-risk', () => { + const baseline = makeSnapshot([ + sym({ fqName: 'ApiKeyWithValue', kind: 'alias' }), + sym({ + fqName: 'ApiKeyWithValue.value', + kind: 'field', + ownerFqName: 'ApiKeyWithValue', + typeRef: { name: 'string' }, + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'OrganizationApiKeyWithValue', kind: 'alias' }), + sym({ + fqName: 'OrganizationApiKeyWithValue.value', + kind: 'field', + ownerFqName: 'OrganizationApiKeyWithValue', + typeRef: { name: 'string' }, + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const fieldRemoval = result.changes.find( + (c) => c.category === 'symbol_removed' && c.old.symbol === 'ApiKeyWithValue.value', + ); + expect(fieldRemoval, 'owned field removal should exist').toBeDefined(); + expect(fieldRemoval!.severity).toBe('soft-risk'); + expect(fieldRemoval!.remediation).toMatch(/Owned by renamed symbol/); + }); + + it('downgrades return_type_changed when old → new pair matches a recorded rename', () => { + const baseline = makeSnapshot([ + sym({ fqName: 'ApiKeyWithValue', kind: 'alias' }), + sym({ + fqName: 'ApiKeyWithValue.value', + kind: 'field', + ownerFqName: 'ApiKeyWithValue', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'ApiKeyService.create', + kind: 'callable', + ownerFqName: 'ApiKeyService', + parameters: [], + returns: { name: 'ApiKeyWithValue' }, + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'OrganizationApiKeyWithValue', kind: 'alias' }), + sym({ + fqName: 'OrganizationApiKeyWithValue.value', + kind: 'field', + ownerFqName: 'OrganizationApiKeyWithValue', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'ApiKeyService.create', + kind: 'callable', + ownerFqName: 'ApiKeyService', + parameters: [], + returns: { name: 'OrganizationApiKeyWithValue' }, + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const returnChange = result.changes.find((c) => c.category === 'return_type_changed'); + expect(returnChange, 'return_type_changed should be classified').toBeDefined(); + expect(returnChange!.severity).toBe('soft-risk'); + expect(returnChange!.remediation).toMatch(/recorded rename/); + }); + + it('also downgrades return_type_changed for `*Iterator[Foo]` style return wrappers', () => { + // bareTypeName strips a single-type-arg generic, so `*Iterator[ApiKey]` + // → `ApiKey` and `*Iterator[OrganizationApiKey]` → `OrganizationApiKey`, + // letting the cascade pair them with the rename map. + const baseline = makeSnapshot([ + sym({ fqName: 'ApiKey', kind: 'alias' }), + sym({ + fqName: 'ApiKey.id', + kind: 'field', + ownerFqName: 'ApiKey', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'ApiKeyService.list_organization', + kind: 'callable', + ownerFqName: 'ApiKeyService', + parameters: [], + returns: { name: 'Iterator' }, + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'OrganizationApiKey', kind: 'alias' }), + sym({ + fqName: 'OrganizationApiKey.id', + kind: 'field', + ownerFqName: 'OrganizationApiKey', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'ApiKeyService.list_organization', + kind: 'callable', + ownerFqName: 'ApiKeyService', + parameters: [], + returns: { name: 'Iterator' }, + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const returnChange = result.changes.find((c) => c.category === 'return_type_changed'); + expect(returnChange, 'iterator return change should be classified').toBeDefined(); + expect(returnChange!.severity).toBe('soft-risk'); + }); + + it('does NOT downgrade when the candidate type drops a baseline field (real reshape)', () => { + // OldType has {a, b}; NewType has only {a}. Removing b is a genuine + // reshape, not a rename. Severity stays at breaking. + const baseline = makeSnapshot([ + sym({ fqName: 'OldType', kind: 'alias' }), + sym({ + fqName: 'OldType.a', + kind: 'field', + ownerFqName: 'OldType', + typeRef: { name: 'string' }, + }), + sym({ + fqName: 'OldType.b', + kind: 'field', + ownerFqName: 'OldType', + typeRef: { name: 'string' }, + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'NewType', kind: 'alias' }), + sym({ + fqName: 'NewType.a', + kind: 'field', + ownerFqName: 'NewType', + typeRef: { name: 'string' }, + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const removal = result.changes.find((c) => c.category === 'symbol_removed' && c.old.symbol === 'OldType'); + expect(removal!.severity).toBe('breaking'); + expect(removal!.remediation).toBeUndefined(); + }); + + it('does NOT downgrade when the candidate type already existed in the baseline (it is a swap, not a rename)', () => { + // Both `Foo` and `Bar` existed in the baseline; only `Foo` is removed. + // This is a deliberate swap to a pre-existing type, not the emitter + // losing track of a name. Severity should stay breaking. + const baseline = makeSnapshot([ + sym({ fqName: 'Foo', kind: 'alias' }), + sym({ fqName: 'Foo.x', kind: 'field', ownerFqName: 'Foo', typeRef: { name: 'string' } }), + sym({ fqName: 'Bar', kind: 'alias' }), + sym({ fqName: 'Bar.x', kind: 'field', ownerFqName: 'Bar', typeRef: { name: 'string' } }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'Bar', kind: 'alias' }), + sym({ fqName: 'Bar.x', kind: 'field', ownerFqName: 'Bar', typeRef: { name: 'string' } }), + ]); + + const result = diffSnapshots(baseline, candidate); + const removal = result.changes.find((c) => c.category === 'symbol_removed' && c.old.symbol === 'Foo'); + expect(removal!.severity).toBe('breaking'); + }); + + it('pairs deterministically with the alphabetically-first matching candidate when several are valid', () => { + // OldType is structurally compatible with both NewTypeA and NewTypeB. + // The pairing must be stable across runs — choose alphabetically first. + const baseline = makeSnapshot([ + sym({ fqName: 'OldType', kind: 'alias' }), + sym({ fqName: 'OldType.f', kind: 'field', ownerFqName: 'OldType', typeRef: { name: 'string' } }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'NewTypeB', kind: 'alias' }), + sym({ fqName: 'NewTypeB.f', kind: 'field', ownerFqName: 'NewTypeB', typeRef: { name: 'string' } }), + sym({ fqName: 'NewTypeA', kind: 'alias' }), + sym({ fqName: 'NewTypeA.f', kind: 'field', ownerFqName: 'NewTypeA', typeRef: { name: 'string' } }), + ]); + + const result = diffSnapshots(baseline, candidate); + const removal = result.changes.find((c) => c.old.symbol === 'OldType'); + expect(removal!.severity).toBe('soft-risk'); + expect(removal!.remediation).toMatch(/renamed to "NewTypeA"/); + }); +}); + +// --------------------------------------------------------------------------- +// Enum canonical-flip detection +// --------------------------------------------------------------------------- + +describe('detectEnumRenames — enum canonical-flip detection', () => { + /** + * Mirror the workos/openapi-spec#17 VaultByokKey case: a baseline enum + * disappears (in dotnet, which can't emit type aliases) when a new enum + * with identical wire values joins the spec and the dedup heuristic + * picks the new shorter name as canonical. The wire values are + * unchanged, so consumer code constructing or matching on these values + * keeps working — only typed-class references need migration. + */ + it('downgrades an enum removal to soft-risk when an identically-valued new enum exists', () => { + const baseline = makeSnapshot([ + sym({ fqName: 'VaultByokKeyVerificationCompletedDataKeyProvider', kind: 'enum' }), + sym({ + fqName: 'VaultByokKeyVerificationCompletedDataKeyProvider.AwsKms', + kind: 'enum_member', + ownerFqName: 'VaultByokKeyVerificationCompletedDataKeyProvider', + value: 'AWS_KMS', + }), + sym({ + fqName: 'VaultByokKeyVerificationCompletedDataKeyProvider.GcpKms', + kind: 'enum_member', + ownerFqName: 'VaultByokKeyVerificationCompletedDataKeyProvider', + value: 'GCP_KMS', + }), + sym({ + fqName: 'VaultByokKeyVerificationCompletedDataKeyProvider.AzureKeyVault', + kind: 'enum_member', + ownerFqName: 'VaultByokKeyVerificationCompletedDataKeyProvider', + value: 'AZURE_KEY_VAULT', + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'VaultByokKeyDeletedDataKeyProvider', kind: 'enum' }), + sym({ + fqName: 'VaultByokKeyDeletedDataKeyProvider.AwsKms', + kind: 'enum_member', + ownerFqName: 'VaultByokKeyDeletedDataKeyProvider', + value: 'AWS_KMS', + }), + sym({ + fqName: 'VaultByokKeyDeletedDataKeyProvider.GcpKms', + kind: 'enum_member', + ownerFqName: 'VaultByokKeyDeletedDataKeyProvider', + value: 'GCP_KMS', + }), + sym({ + fqName: 'VaultByokKeyDeletedDataKeyProvider.AzureKeyVault', + kind: 'enum_member', + ownerFqName: 'VaultByokKeyDeletedDataKeyProvider', + value: 'AZURE_KEY_VAULT', + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const removal = result.changes.find( + (c) => c.category === 'symbol_removed' && c.old.symbol === 'VaultByokKeyVerificationCompletedDataKeyProvider', + ); + expect(removal, 'enum removal change should exist').toBeDefined(); + expect(removal!.severity).toBe('soft-risk'); + expect(removal!.remediation).toMatch(/identical wire values/); + }); + + it('cascades the downgrade to enum_member removals owned by a renamed enum', () => { + const baseline = makeSnapshot([ + sym({ fqName: 'ApplicationsOrder', kind: 'enum' }), + sym({ + fqName: 'ApplicationsOrder.Asc', + kind: 'enum_member', + ownerFqName: 'ApplicationsOrder', + value: 'asc', + }), + sym({ + fqName: 'ApplicationsOrder.Desc', + kind: 'enum_member', + ownerFqName: 'ApplicationsOrder', + value: 'desc', + }), + sym({ + fqName: 'ApplicationsOrder.Normal', + kind: 'enum_member', + ownerFqName: 'ApplicationsOrder', + value: 'normal', + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'ApiKeysOrder', kind: 'enum' }), + sym({ + fqName: 'ApiKeysOrder.Asc', + kind: 'enum_member', + ownerFqName: 'ApiKeysOrder', + value: 'asc', + }), + sym({ + fqName: 'ApiKeysOrder.Desc', + kind: 'enum_member', + ownerFqName: 'ApiKeysOrder', + value: 'desc', + }), + sym({ + fqName: 'ApiKeysOrder.Normal', + kind: 'enum_member', + ownerFqName: 'ApiKeysOrder', + value: 'normal', + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const memberRemoval = result.changes.find( + (c) => c.category === 'symbol_removed' && c.old.symbol === 'ApplicationsOrder.Asc', + ); + expect(memberRemoval, 'enum_member removal should exist').toBeDefined(); + expect(memberRemoval!.severity).toBe('soft-risk'); + expect(memberRemoval!.remediation).toMatch(/Owned by renamed symbol "ApplicationsOrder"/); + }); + + it('does NOT downgrade when the candidate enum has different wire values', () => { + // Same value-set size but different value — narrowing or shifting an + // enum is a real wire-format break, not a rename. + const baseline = makeSnapshot([ + sym({ fqName: 'OldEnum', kind: 'enum' }), + sym({ + fqName: 'OldEnum.A', + kind: 'enum_member', + ownerFqName: 'OldEnum', + value: 'a', + }), + sym({ + fqName: 'OldEnum.B', + kind: 'enum_member', + ownerFqName: 'OldEnum', + value: 'b', + }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'NewEnum', kind: 'enum' }), + sym({ + fqName: 'NewEnum.A', + kind: 'enum_member', + ownerFqName: 'NewEnum', + value: 'a', + }), + sym({ + fqName: 'NewEnum.C', + kind: 'enum_member', + ownerFqName: 'NewEnum', + value: 'c', + }), + ]); + + const result = diffSnapshots(baseline, candidate); + const removal = result.changes.find((c) => c.category === 'symbol_removed' && c.old.symbol === 'OldEnum'); + expect(removal!.severity).toBe('breaking'); + expect(removal!.remediation).toBeUndefined(); + }); + + it('does NOT downgrade when the candidate enum is a strict superset (extra value is a real addition consumers may need to handle)', () => { + // Strict-superset enum is not a rename — consumers may need to add + // handling for the new wire value. If language emitters emit `Unknown` + // sentinels this is forward-compatible at the deserialization layer, + // but the differ shouldn't decide that on the consumer's behalf. + const baseline = makeSnapshot([ + sym({ fqName: 'OldEnum', kind: 'enum' }), + sym({ fqName: 'OldEnum.A', kind: 'enum_member', ownerFqName: 'OldEnum', value: 'a' }), + ]); + const candidate = makeSnapshot([ + sym({ fqName: 'NewEnum', kind: 'enum' }), + sym({ fqName: 'NewEnum.A', kind: 'enum_member', ownerFqName: 'NewEnum', value: 'a' }), + sym({ fqName: 'NewEnum.B', kind: 'enum_member', ownerFqName: 'NewEnum', value: 'b' }), + ]); + + const result = diffSnapshots(baseline, candidate); + const removal = result.changes.find((c) => c.category === 'symbol_removed' && c.old.symbol === 'OldEnum'); + expect(removal!.severity).toBe('breaking'); + }); +});