From 930146a451aa406b63264fec41b5944cc28aef75 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:09:23 +0200 Subject: [PATCH 1/4] fix: use raw enum values for cross-enum conflict detection GetValues() returns prefixed identifier names for enums already marked as conflicting. Using it in the cross-enum comparison loop makes the result order-dependent: an enum that was prefixed in an earlier iteration produces keys like FooStateReady, which no longer match the unprefixed key Ready in a later enum, so the later enum misses the conflict and stays unprefixed. Fix by comparing Schema.EnumValues keys directly, which are always the raw (unprefixed) Go-safe identifiers regardless of PrefixTypeName state. --- .../test/issues/issue-1189/issue1189.gen.go | 8 +-- pkg/codegen/codegen.go | 52 ++++++++++++++++-- pkg/codegen/codegen_test.go | 55 +++++++++++++++++++ 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/internal/test/issues/issue-1189/issue1189.gen.go b/internal/test/issues/issue-1189/issue1189.gen.go index 0296cf0d90..630da8c4ce 100644 --- a/internal/test/issues/issue-1189/issue1189.gen.go +++ b/internal/test/issues/issue-1189/issue1189.gen.go @@ -59,16 +59,16 @@ func (e TestFieldB) Valid() bool { // Defines values for TestFieldC1. const ( - Bar TestFieldC1 = "bar" - Foo TestFieldC1 = "foo" + TestFieldC1Bar TestFieldC1 = "bar" + TestFieldC1Foo TestFieldC1 = "foo" ) // Valid indicates whether the value is a known member of the TestFieldC1 enum. func (e TestFieldC1) Valid() bool { switch e { - case Bar: + case TestFieldC1Bar: return true - case Foo: + case TestFieldC1Foo: return true default: return false diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 22317bda16..643cc12901 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -1021,16 +1021,16 @@ func GenerateEnums(t *template.Template, types []TypeDefinition) (string, error) // Now, go through all the enums, and figure out if we have conflicts with // any others. + // + // Stage 1: detect conflicts based on raw (unprefixed) values. This catches + // the case where two enums share the same value string (e.g. both have + // "running"), regardless of which order they appear. for i := range enums { - // Look through all other enums not compared so far. Make sure we don't - // compare against self. e1 := enums[i] for j := i + 1; j < len(enums); j++ { e2 := enums[j] - - for e1key := range e1.GetValues() { - _, found := e2.GetValues()[e1key] - if found { + for e1key := range e1.Schema.EnumValues { + if _, found := e2.Schema.EnumValues[e1key]; found { e1.PrefixTypeName = true e2.PrefixTypeName = true enums[i] = e1 @@ -1039,6 +1039,46 @@ func GenerateEnums(t *template.Template, types []TypeDefinition) (string, error) } } } + } + + // Stage 2: iteratively detect effective-name conflicts. After Stage 1 some + // enums are now prefixed; their prefixed constant names (e.g. "Enum1One") + // may collide with another enum's raw constant name. We repeat until + // stable. + for { + changed := false + for i := range enums { + e1 := enums[i] + for j := i + 1; j < len(enums); j++ { + e2 := enums[j] + if e1.PrefixTypeName && e2.PrefixTypeName { + continue + } + for e1key := range e1.GetValues() { + if _, found := e2.GetValues()[e1key]; found { + if !e1.PrefixTypeName { + e1.PrefixTypeName = true + enums[i] = e1 + changed = true + } + if !e2.PrefixTypeName { + e2.PrefixTypeName = true + enums[j] = e2 + changed = true + } + break + } + } + } + } + if !changed { + break + } + } + + // Check each enum against global type names and self-conflict. + for i := range enums { + e1 := enums[i] // now see if this enum conflicts with any global type names. for _, tp := range types { diff --git a/pkg/codegen/codegen_test.go b/pkg/codegen/codegen_test.go index 037c9e1886..8251eda908 100644 --- a/pkg/codegen/codegen_test.go +++ b/pkg/codegen/codegen_test.go @@ -329,5 +329,60 @@ paths: assert.Contains(t, code, "roleName string") } +// TestEnumConflictDetectionOrderIndependent checks that conflict detection +// doesn't miss overlaps because an enum was already marked for prefixing. +func TestEnumConflictDetectionOrderIndependent(t *testing.T) { + // AState+BState share "running" (both prefixed), AState+CState share "migrating". + // The bug: once AState was marked, GetValues() returned prefixed names that + // no longer matched CState's raw values, so CState's conflict was missed. + const spec = ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: Test Enum Conflict Detection +paths: {} +components: + schemas: + AState: + type: string + enum: + - running + - migrating + BState: + type: string + enum: + - running + CState: + type: string + enum: + - migrating +` + loader := openapi3.NewLoader() + swagger, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + opts := Configuration{ + PackageName: "api", + Generate: GenerateOptions{ + Models: true, + }, + OutputOptions: OutputOptions{ + SkipPrune: true, + }, + } + + code, err := Generate(swagger, opts) + require.NoError(t, err) + + _, err = format.Source([]byte(code)) + require.NoError(t, err) + + // All three enums share values with at least one other enum; all must be prefixed. + assert.Contains(t, code, "AStateRunning") + assert.Contains(t, code, "AStateMigrating") + assert.Contains(t, code, "BStateRunning") + assert.Contains(t, code, "CStateMigrating") +} + //go:embed test_spec.yaml var testOpenAPIDefinition string From be163f0c90185987f8563f8417aa2f2422c582b6 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:30:53 +0200 Subject: [PATCH 2/4] test: add order-independence test for both schema orderings --- pkg/codegen/codegen_test.go | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/pkg/codegen/codegen_test.go b/pkg/codegen/codegen_test.go index 8251eda908..2ac29e93ce 100644 --- a/pkg/codegen/codegen_test.go +++ b/pkg/codegen/codegen_test.go @@ -384,5 +384,80 @@ components: assert.Contains(t, code, "CStateMigrating") } +// TestEnumConflictDetectionBothOrders verifies that enum conflict detection +// produces identical output regardless of the order schemas appear in the spec. +// The old GetValues()-based approach was order-dependent: processing AState +// before CState caused CState to be left unprefixed, while reversing the order +// would prefix it. Go map iteration is non-deterministic (randomized since +// Go 1.12), so this was a latent correctness bug. +func TestEnumConflictDetectionBothOrders(t *testing.T) { + specAFirst := ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: Test +paths: {} +components: + schemas: + AState: + type: string + enum: [running, migrating] + BState: + type: string + enum: [running] + CState: + type: string + enum: [migrating] +` + specCFirst := ` +openapi: "3.0.0" +info: + version: 1.0.0 + title: Test +paths: {} +components: + schemas: + CState: + type: string + enum: [migrating] + BState: + type: string + enum: [running] + AState: + type: string + enum: [running, migrating] +` + opts := Configuration{ + PackageName: "api", + Generate: GenerateOptions{Models: true}, + OutputOptions: OutputOptions{ + SkipPrune: true, + }, + } + + loader := openapi3.NewLoader() + + swaggerA, err := loader.LoadFromData([]byte(specAFirst)) + require.NoError(t, err) + codeA, err := Generate(swaggerA, opts) + require.NoError(t, err) + + swaggerC, err := loader.LoadFromData([]byte(specCFirst)) + require.NoError(t, err) + codeC, err := Generate(swaggerC, opts) + require.NoError(t, err) + + // Both orderings must produce fully prefixed constants. + for _, code := range []string{codeA, codeC} { + assert.Contains(t, code, "AStateRunning") + assert.Contains(t, code, "AStateMigrating") + assert.Contains(t, code, "BStateRunning") + assert.Contains(t, code, "CStateMigrating") + // Unprefixed names must not appear as standalone constants. + assert.NotContains(t, code, "\tRunning ") + assert.NotContains(t, code, "\tMigrating ") + } +} + //go:embed test_spec.yaml var testOpenAPIDefinition string From e13b89533e76543d5bba8dbab147870526455441 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:33:22 +0200 Subject: [PATCH 3/4] feat: add old-enum-conflict-detection compatibility flag --- pkg/codegen/codegen.go | 102 +++++++++++++++++++++-------------- pkg/codegen/configuration.go | 8 +++ 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 643cc12901..2bcd4e8a3c 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -1021,58 +1021,80 @@ func GenerateEnums(t *template.Template, types []TypeDefinition) (string, error) // Now, go through all the enums, and figure out if we have conflicts with // any others. - // - // Stage 1: detect conflicts based on raw (unprefixed) values. This catches - // the case where two enums share the same value string (e.g. both have - // "running"), regardless of which order they appear. - for i := range enums { - e1 := enums[i] - for j := i + 1; j < len(enums); j++ { - e2 := enums[j] - for e1key := range e1.Schema.EnumValues { - if _, found := e2.Schema.EnumValues[e1key]; found { - e1.PrefixTypeName = true - e2.PrefixTypeName = true - enums[i] = e1 - enums[j] = e2 - break + if globalState.options.Compatibility.OldEnumConflictDetection { + // Legacy behavior (pre-v2.7.1): compare generated constant names via + // GetValues(). This is order-dependent because GetValues() reflects + // already-applied prefixes, so enums processed later may miss conflicts + // with enums that were prefixed in an earlier iteration. Preserved here + // as an escape hatch for users who need to keep existing generated output. + for i := range enums { + e1 := enums[i] + for j := i + 1; j < len(enums); j++ { + e2 := enums[j] + for e1key := range e1.GetValues() { + if _, found := e2.GetValues()[e1key]; found { + e1.PrefixTypeName = true + e2.PrefixTypeName = true + enums[i] = e1 + enums[j] = e2 + break + } } } } - } - - // Stage 2: iteratively detect effective-name conflicts. After Stage 1 some - // enums are now prefixed; their prefixed constant names (e.g. "Enum1One") - // may collide with another enum's raw constant name. We repeat until - // stable. - for { - changed := false + } else { + // Stage 1: detect conflicts based on raw (unprefixed) values. This catches + // the case where two enums share the same value string (e.g. both have + // "running"), regardless of which order they appear. for i := range enums { e1 := enums[i] for j := i + 1; j < len(enums); j++ { e2 := enums[j] - if e1.PrefixTypeName && e2.PrefixTypeName { - continue - } - for e1key := range e1.GetValues() { - if _, found := e2.GetValues()[e1key]; found { - if !e1.PrefixTypeName { - e1.PrefixTypeName = true - enums[i] = e1 - changed = true - } - if !e2.PrefixTypeName { - e2.PrefixTypeName = true - enums[j] = e2 - changed = true - } + for e1key := range e1.Schema.EnumValues { + if _, found := e2.Schema.EnumValues[e1key]; found { + e1.PrefixTypeName = true + e2.PrefixTypeName = true + enums[i] = e1 + enums[j] = e2 break } } } } - if !changed { - break + + // Stage 2: iteratively detect effective-name conflicts. After Stage 1 some + // enums are now prefixed; their prefixed constant names (e.g. "Enum1One") + // may collide with another enum's raw constant name. We repeat until + // stable. + for { + changed := false + for i := range enums { + e1 := enums[i] + for j := i + 1; j < len(enums); j++ { + e2 := enums[j] + if e1.PrefixTypeName && e2.PrefixTypeName { + continue + } + for e1key := range e1.GetValues() { + if _, found := e2.GetValues()[e1key]; found { + if !e1.PrefixTypeName { + e1.PrefixTypeName = true + enums[i] = e1 + changed = true + } + if !e2.PrefixTypeName { + e2.PrefixTypeName = true + enums[j] = e2 + changed = true + } + break + } + } + } + } + if !changed { + break + } } } diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index dbcb0a6874..e34711a493 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -299,6 +299,14 @@ type CompatibilityOptions struct { // are treated as required. // Please see https://github.com/oapi-codegen/oapi-codegen/issues/2267 HeadersImplicitlyRequired bool `yaml:"headers-implicitly-required,omitempty"` + + // OldEnumConflictDetection reverts enum conflict detection to the pre-v2.7.1 + // behavior, which compared generated constant names (GetValues()) rather than + // raw schema values. The old approach was order-dependent: whether an enum got + // prefixed could vary based on map iteration order (non-deterministic in Go). + // Set this only if you need to preserve existing generated output while you + // migrate. Please see https://github.com/oapi-codegen/oapi-codegen/issues/2391 + OldEnumConflictDetection bool `yaml:"old-enum-conflict-detection,omitempty"` } func (co CompatibilityOptions) Validate() map[string]string { From 204fe578f99daff3a506a0e9ffd916c6883564d6 Mon Sep 17 00:00:00 2001 From: Natalie Perret <11332444+natalie-o-perret@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:57:47 +0200 Subject: [PATCH 4/4] fix: reuse old-enum-conflicts flag for legacy conflict detection path --- pkg/codegen/codegen.go | 2 +- pkg/codegen/configuration.go | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 2bcd4e8a3c..cd2b49fb76 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -1021,7 +1021,7 @@ func GenerateEnums(t *template.Template, types []TypeDefinition) (string, error) // Now, go through all the enums, and figure out if we have conflicts with // any others. - if globalState.options.Compatibility.OldEnumConflictDetection { + if globalState.options.Compatibility.OldEnumConflicts { // Legacy behavior (pre-v2.7.1): compare generated constant names via // GetValues(). This is order-dependent because GetValues() reflects // already-applied prefixes, so enums processed later may miss conflicts diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index e34711a493..4dc953cfc6 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -231,8 +231,13 @@ type CompatibilityOptions struct { // Enum values can generate conflicting typenames, so we've updated the // code for enum generation to avoid these conflicts, but it will result // in some enum types being renamed in existing code. Set OldEnumConflicts to true - // to revert to old behavior. Please see: + // to revert to old behavior. + // As of v2.7.1, this also reverts the order-independent conflict detection + // fix (issue #2391): the legacy GetValues()-based path is order-dependent + // and may produce different output on different runs, but is provided as + // an escape hatch for users who need to preserve existing generated code. // Please see https://github.com/oapi-codegen/oapi-codegen/issues/549 + // Please see https://github.com/oapi-codegen/oapi-codegen/issues/2391 OldEnumConflicts bool `yaml:"old-enum-conflicts,omitempty"` // It was a mistake to generate a go type definition for every $ref in // the OpenAPI schema. New behavior uses type aliases where possible, but @@ -300,13 +305,6 @@ type CompatibilityOptions struct { // Please see https://github.com/oapi-codegen/oapi-codegen/issues/2267 HeadersImplicitlyRequired bool `yaml:"headers-implicitly-required,omitempty"` - // OldEnumConflictDetection reverts enum conflict detection to the pre-v2.7.1 - // behavior, which compared generated constant names (GetValues()) rather than - // raw schema values. The old approach was order-dependent: whether an enum got - // prefixed could vary based on map iteration order (non-deterministic in Go). - // Set this only if you need to preserve existing generated output while you - // migrate. Please see https://github.com/oapi-codegen/oapi-codegen/issues/2391 - OldEnumConflictDetection bool `yaml:"old-enum-conflict-detection,omitempty"` } func (co CompatibilityOptions) Validate() map[string]string {