From d1eb33afe7e641dafe44ee33267fa94678f981b1 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Fri, 2 Oct 2026 13:34:17 -0400 Subject: [PATCH 01/13] fix(enums): derive valid member names for underscore-digit enum values Enum values such as `_1ST` passed through the shared sanitizer unchanged because the digit-to-words rewrite only ran when the first character was a digit. The caser then dropped the leading underscore and TypeScript, Python (enumFormat: enum), C#, PHP, Ruby, Unity and MCP TypeScript emitted a digit-leading member name that failed to format or compile. Go, Java and Terraform already re-sanitize the cased name, so they were unaffected. The affected targets now re-sanitize the cased name when it starts with a digit, producing the same word-based names Go and Java derive. --- .changesets/1790962042-cf634029.yaml | 16 +++++++ .../templates/csharp/includes/sanitization.ts | 6 ++- .../mcp-typescript/includes/sanitization.ts | 6 ++- .../templates/php/includes/sanitization.ts | 3 ++ .../pythonv2/includes/sanitization.ts | 6 ++- .../templates/ruby/includes/sanitization.ts | 3 ++ .../typescriptv2/includes/sanitization.ts | 6 ++- .../templates/unity/includes/sanitization.ts | 6 ++- .../uber/enum-leading-underscore-digit.yaml | 42 +++++++++++++++++++ 9 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 .changesets/1790962042-cf634029.yaml create mode 100644 tests/specs/fragments/uber/enum-leading-underscore-digit.yaml diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml new file mode 100644 index 00000000..bd271e3d --- /dev/null +++ b/.changesets/1790962042-cf634029.yaml @@ -0,0 +1,16 @@ +id: 1790962042-cf634029 +features: + - enums +targets: + - csharp + - mcp-typescript + - php + - pythonv2 + - ruby + - typescriptv2 + - unity +type: fix +bump: patch +description: derive valid enum member names for values that start with an underscore followed by digits +author: AshGodfrey +date: "2026-10-02" diff --git a/templates/templates/csharp/includes/sanitization.ts b/templates/templates/csharp/includes/sanitization.ts index e1ef72fc..0b3cc08b 100644 --- a/templates/templates/csharp/includes/sanitization.ts +++ b/templates/templates/csharp/includes/sanitization.ts @@ -621,7 +621,11 @@ function getEnumName(value: string): string { } name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); diff --git a/templates/templates/mcp-typescript/includes/sanitization.ts b/templates/templates/mcp-typescript/includes/sanitization.ts index 810a2a71..5326b6cd 100644 --- a/templates/templates/mcp-typescript/includes/sanitization.ts +++ b/templates/templates/mcp-typescript/includes/sanitization.ts @@ -401,7 +401,11 @@ function getEnumName(value) { name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); diff --git a/templates/templates/php/includes/sanitization.ts b/templates/templates/php/includes/sanitization.ts index a4c5ee7a..9a962a7d 100644 --- a/templates/templates/php/includes/sanitization.ts +++ b/templates/templates/php/includes/sanitization.ts @@ -664,6 +664,9 @@ function getEnumName(value: string): string { } name = caser().ToPascal(sanitizeName(name)); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } if (name === "Class") { name = "Class_"; } diff --git a/templates/templates/pythonv2/includes/sanitization.ts b/templates/templates/pythonv2/includes/sanitization.ts index 8380924f..cab4c0f2 100644 --- a/templates/templates/pythonv2/includes/sanitization.ts +++ b/templates/templates/pythonv2/includes/sanitization.ts @@ -2340,7 +2340,11 @@ function getEnumName(value: string): string { name = sanitizeName(name); - return caser().ToSNAKE(name); + name = caser().ToSNAKE(name); + if (/^[0-9]/.test(name)) { + name = caser().ToSNAKE(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); diff --git a/templates/templates/ruby/includes/sanitization.ts b/templates/templates/ruby/includes/sanitization.ts index 6f730db1..8a816407 100644 --- a/templates/templates/ruby/includes/sanitization.ts +++ b/templates/templates/ruby/includes/sanitization.ts @@ -829,6 +829,9 @@ function getEnumName(value: string): string { name = sanitizeName(name); name = caser().ToSNAKE(name); + if (/^[0-9]/.test(name)) { + name = caser().ToSNAKE(sanitizeName(name)); + } if (rubyReservedKeywords.includes(name)) { name += "_VALUE"; diff --git a/templates/templates/typescriptv2/includes/sanitization.ts b/templates/templates/typescriptv2/includes/sanitization.ts index e9fe309c..03731ac8 100644 --- a/templates/templates/typescriptv2/includes/sanitization.ts +++ b/templates/templates/typescriptv2/includes/sanitization.ts @@ -1131,7 +1131,11 @@ function getEnumName(value) { name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); diff --git a/templates/templates/unity/includes/sanitization.ts b/templates/templates/unity/includes/sanitization.ts index e8c20adc..e4e1efa2 100644 --- a/templates/templates/unity/includes/sanitization.ts +++ b/templates/templates/unity/includes/sanitization.ts @@ -372,7 +372,11 @@ function getEnumName(value: string): string { } name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } registerTemplateFunc("getEnumName", getEnumName); diff --git a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml new file mode 100644 index 00000000..363254f3 --- /dev/null +++ b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml @@ -0,0 +1,42 @@ +openapi: 3.1.0 +security: + - apiKeyAuth: [] + - oauth2: [] + - clientCredentials: [read, write] + - customSchemeAppId: [] + - basicHttp: [] + - accessToken: [] + - {} +paths: + /enums/leading-underscore-digit: + get: + operationId: getEnumLeadingUnderscoreDigit + description: >- + Returns an object whose enum property has values that start with an + underscore followed by digits (`_1ST`, `_2FA`, `_3D`), the shape + protobuf-derived documents use for numeric-looking identifiers. Checks + that every target derives a valid member name for such values instead + of emitting a digit-leading identifier that fails to compile or format. + tags: + - generation + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + required: + - accountType + properties: + accountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitEnum" +components: + schemas: + LeadingUnderscoreDigitEnum: + type: string + enum: + - UNSPECIFIED + - _1ST + - _2FA + - _3D From 7bace9f1c7489d306f7d428219f5087fd4646819 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Fri, 2 Oct 2026 13:59:39 -0400 Subject: [PATCH 02/13] fix(mcp): apply the digit guard to the derived enum name helper as well --- templates/templates/mcp-typescript/includes/sanitization.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/templates/mcp-typescript/includes/sanitization.ts b/templates/templates/mcp-typescript/includes/sanitization.ts index 5326b6cd..bb69e6f9 100644 --- a/templates/templates/mcp-typescript/includes/sanitization.ts +++ b/templates/templates/mcp-typescript/includes/sanitization.ts @@ -164,7 +164,11 @@ function sanitizeEnumName(value: string): string { name = sanitizeName(name); - return caser().ToPascal(name); + name = caser().ToPascal(name); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } + return name; } // @ts-ignore From bd70985f35a8bec62a40762ad34e283d02a5ab72 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Fri, 2 Oct 2026 14:36:04 -0400 Subject: [PATCH 03/13] fix(typescript,unity,mcp): suffix enum members that share a derived name and casing Values such as `_1` and `1` now both derive the member name `One`, and the casing-class suffix alone left both as `OneUpper`, so TypeScript, MCP TypeScript and Unity emitted duplicate members. These targets now apply the same numeric suffix fallback C#, PHP, Python and Ruby already use. The changeset also records that TypeScript and MCP TypeScript union enums rename members for digits-only values such as `_1` from `1` to `One`. --- .changesets/1790962042-cf634029.yaml | 2 +- templates/templates/mcp-typescript/includes/types.ts | 11 ++++++++++- .../templates/typescriptv2/includes/sanitization.ts | 11 ++++++++++- templates/templates/unity/includes/sanitization.ts | 11 ++++++++++- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index bd271e3d..e1286ccf 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -11,6 +11,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits +description: derive valid enum member names for values that start with an underscore followed by digits; in TypeScript and MCP TypeScript union enums, members for digits-only values such as `_1` are renamed from `1` to `One`, and values that share a derived name now receive unique suffixes author: AshGodfrey date: "2026-10-02" diff --git a/templates/templates/mcp-typescript/includes/types.ts b/templates/templates/mcp-typescript/includes/types.ts index cf95fd2e..a600552e 100644 --- a/templates/templates/mcp-typescript/includes/types.ts +++ b/templates/templates/mcp-typescript/includes/types.ts @@ -128,10 +128,19 @@ function getEnumNamesFromValues(values: string[]): string[] { names[name] += 1; } + let seen = {}; for (const value of values) { let name = sanitizeEnumName(value); if (names[name] > 1) { - name = `${name}${caser().ToPascal(getCasing(value))}`; + let candidate = `${name}${caser().ToPascal(getCasing(value))}`; + if (seen[candidate]) { + let suffix = seen[candidate]; + seen[candidate] += 1; + candidate = `${candidate}${suffix}`; + } else { + seen[candidate] = 1; + } + name = candidate; } enumNames.push(name); diff --git a/templates/templates/typescriptv2/includes/sanitization.ts b/templates/templates/typescriptv2/includes/sanitization.ts index 03731ac8..34e5626f 100644 --- a/templates/templates/typescriptv2/includes/sanitization.ts +++ b/templates/templates/typescriptv2/includes/sanitization.ts @@ -1109,10 +1109,19 @@ function getEnumNamesFromValues(values) { names[name] += 1; } + let seen = {}; for (const value of values) { let name = getEnumName(value); if (names[name] > 1) { - name = `${name}${caser().ToPascal(getCasing(value))}`; + let candidate = `${name}${caser().ToPascal(getCasing(value))}`; + if (seen[candidate]) { + let suffix = seen[candidate]; + seen[candidate] += 1; + candidate = `${candidate}${suffix}`; + } else { + seen[candidate] = 1; + } + name = candidate; } enumNames.push(name); diff --git a/templates/templates/unity/includes/sanitization.ts b/templates/templates/unity/includes/sanitization.ts index e4e1efa2..a6c7cfe8 100644 --- a/templates/templates/unity/includes/sanitization.ts +++ b/templates/templates/unity/includes/sanitization.ts @@ -396,10 +396,19 @@ function getEnumNamesFromValues(values: string[]): string[] { names[name] += 1; }); + const seen = {}; values.forEach((value) => { let name = getEnumName(value); if (names[name] > 1) { - name = `${name}${caser().ToPascal(getCasing(value))}`; + let candidate = `${name}${caser().ToPascal(getCasing(value))}`; + if (seen[candidate]) { + const suffix = seen[candidate]; + seen[candidate] += 1; + candidate = `${candidate}${suffix}`; + } else { + seen[candidate] = 1; + } + name = candidate; } enumNames.push(sanitizeFieldName(name)); From c4b7350963b2fbd580d7f508edeac24cf8557591 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Fri, 2 Oct 2026 15:03:46 -0400 Subject: [PATCH 04/13] fix(validation): re-sanitize digit-leading enum names in the collision validator The enum collision validator mirrors each target's member name derivation to predict collisions before generation. The C#, Java and Python mirrors still cased `_1` to `1`, while the templates now derive `One`, so a document with both `_1` and `1` passed validation and reached the templates as a collision. The mirrors now apply the same leading-digit re-sanitize step as the templates, so such documents are reported up front like any other pair that normalizes to the same name. --- .changesets/1790962042-cf634029.yaml | 2 +- .../validation/sanitization/sanitization.go | 15 +++++++-- internal/validation/validateenums_test.go | 32 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index e1286ccf..2cf16764 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -11,6 +11,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; in TypeScript and MCP TypeScript union enums, members for digits-only values such as `_1` are renamed from `1` to `One`, and values that share a derived name now receive unique suffixes +description: derive valid enum member names for values that start with an underscore followed by digits; in TypeScript and MCP TypeScript union enums, members for digits-only values such as `_1` are renamed from `1` to `One`, and the enum validator now reports values that collide once re-sanitized author: AshGodfrey date: "2026-10-02" diff --git a/internal/validation/sanitization/sanitization.go b/internal/validation/sanitization/sanitization.go index 1a74ec9b..d1a47463 100644 --- a/internal/validation/sanitization/sanitization.go +++ b/internal/validation/sanitization/sanitization.go @@ -219,7 +219,7 @@ func getEnumSanitizationMethods() *sequencedmap.Map[string, func(string) string] s = "Unknown" } - return casing.New().ToPascal(sanitization.SanitizeName(s)) + return resanitizeLeadingDigit(sanitization.SanitizeName(s), casing.New().ToPascal) }) sanitizationMethods.Set("go", func(s string) string { s = strings.TrimSpace(s) @@ -235,7 +235,7 @@ func getEnumSanitizationMethods() *sequencedmap.Map[string, func(string) string] s = "Unknown" } - return casing.New().ToPascal(sanitization.SanitizeName(s)) + return resanitizeLeadingDigit(sanitization.SanitizeName(s), casing.New().ToPascal) }) sanitizationMethods.Set("python", func(s string) string { s = strings.TrimSpace(s) @@ -243,12 +243,21 @@ func getEnumSanitizationMethods() *sequencedmap.Map[string, func(string) string] s = "unknown" } - return casing.New().ToSNAKE(sanitization.SanitizeName(s)) + return resanitizeLeadingDigit(sanitization.SanitizeName(s), casing.New().ToSNAKE) }) return sanitizationMethods } +func resanitizeLeadingDigit(name string, caseName func(string) string) string { + cased := caseName(name) + if cased == "" || cased[0] < '0' || cased[0] > '9' { + return cased + } + + return caseName(sanitization.SanitizeName(cased)) +} + func getEnumNameSanitizationMethods() *sequencedmap.Map[string, func(string, int) string] { baseMethods := getEnumSanitizationMethods() diff --git a/internal/validation/validateenums_test.go b/internal/validation/validateenums_test.go index 98f2ac40..a7533bca 100644 --- a/internal/validation/validateenums_test.go +++ b/internal/validation/validateenums_test.go @@ -360,6 +360,38 @@ components: "validation error: [line 25:11] generator-validate-enums - enum value `EAN13` (`Ean13Upper`) will collide with `EAN_13` (`Ean13Upper`) [line `23`] when normalized, try using `x-speakeasy-enums`", }, }, + { + name: "underscore-digit enum values collide once re-sanitized", + args: args{ + schema: `openapi: 3.1.0 +info: + title: Test API + version: 1.0.0 +servers: + - url: http://localhost:8080 +paths: + /test: + get: + operationId: test + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/enum' +components: + schemas: + enum: + type: string + enum: + - _1 + - "1"`, + }, + wantErrs: []string{ + "validation error: [line 24:11] generator-validate-enums - enum value `1` (`OneUpper`) will collide with `_1` (`OneUpper`) [line `23`] when normalized, try using `x-speakeasy-enums`", + }, + }, { name: "duplicate enum values", args: args{ From 84ccdb5540df39038bb0ff8fc89e49b835b197a7 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Fri, 2 Oct 2026 16:22:44 -0400 Subject: [PATCH 05/13] fix(typescript,mcp): add numericEnumMemberNames option for digits-only enum values TypeScript and MCP TypeScript union enums accept a numeric key for values that are only digits once the leading underscore is removed, so `_1` has always generated and compiled as `1: "_1"`. Applying the leading-digit re-sanitize to those values would rename working members to `One`. The re-sanitize now runs unconditionally for values with letters after the digits (`_1ST`), which never produced a valid identifier, and for digits-only values only when the new `numericEnumMemberNames` option is `words`. The option defaults to `words` for new SDKs and `legacy` for existing ones, following the other new-SDK-defaulted options. --- .changesets/1790962042-cf634029.yaml | 2 +- templates/templates/mcp-typescript/config.ts | 9 +++++++++ .../templates/mcp-typescript/includes/sanitization.ts | 11 +++++++++-- templates/templates/typescriptv2/config.ts | 9 +++++++++ .../templates/typescriptv2/includes/sanitization.ts | 9 ++++++++- tests/config/review/typescriptv2/.speakeasy/gen.yaml | 1 + zSDKs/mcp-typescript/.speakeasy/gen.lock | 2 +- zSDKs/mcp-typescript/.speakeasy/gen.yaml | 1 + zSDKs/sdk-typescriptv2/.speakeasy/gen.lock | 2 +- zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml | 1 + 10 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index 2cf16764..9a5be793 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -11,6 +11,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; in TypeScript and MCP TypeScript union enums, members for digits-only values such as `_1` are renamed from `1` to `One`, and the enum validator now reports values that collide once re-sanitized +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs, and the enum validator now reports values that collide once re-sanitized author: AshGodfrey date: "2026-10-02" diff --git a/templates/templates/mcp-typescript/config.ts b/templates/templates/mcp-typescript/config.ts index 8869e281..5b8fee09 100644 --- a/templates/templates/mcp-typescript/config.ts +++ b/templates/templates/mcp-typescript/config.ts @@ -422,6 +422,15 @@ function getConfigFields( ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, + numericEnumMemberNames: { + Name: "numericEnumMemberNames", + Required: false, + DefaultValue: newSDK ? "words" : "legacy", + Description: + 'Member names for enum values that are only digits once a leading underscore is removed, such as `_1` or `_2024`. "words" spells the number out (`One`, `TwoThousandAndTwentyFour`); "legacy" (deprecated) keeps the numeric key (`1`, `2024`). Values with letters after the digits (`_1ST`) always become words.', + ValidationRegex: /^(words|legacy)$/.source, + ValidationMessage: "words or legacy only", + }, validateResponse: { Name: "validateResponse", Required: false, diff --git a/templates/templates/mcp-typescript/includes/sanitization.ts b/templates/templates/mcp-typescript/includes/sanitization.ts index bb69e6f9..066bfff2 100644 --- a/templates/templates/mcp-typescript/includes/sanitization.ts +++ b/templates/templates/mcp-typescript/includes/sanitization.ts @@ -165,7 +165,7 @@ function sanitizeEnumName(value: string): string { name = sanitizeName(name); name = caser().ToPascal(name); - if (/^[0-9]/.test(name)) { + if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name)) { name = caser().ToPascal(sanitizeName(name)); } return name; @@ -406,7 +406,7 @@ function getEnumName(value) { name = sanitizeName(name); name = caser().ToPascal(name); - if (/^[0-9]/.test(name)) { + if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name)) { name = caser().ToPascal(sanitizeName(name)); } return name; @@ -414,6 +414,13 @@ function getEnumName(value) { registerTemplateFunc("getEnumName", getEnumName); +function keepsNumericEnumMemberName(name: string): boolean { + return ( + context.Global.Config.NumericEnumMemberNames === "legacy" && + /^[0-9]+(E[0-9]+)?$/.test(name) + ); +} + // @ts-ignore function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { diff --git a/templates/templates/typescriptv2/config.ts b/templates/templates/typescriptv2/config.ts index 85b71d67..329148be 100644 --- a/templates/templates/typescriptv2/config.ts +++ b/templates/templates/typescriptv2/config.ts @@ -844,6 +844,15 @@ function getConfigFields( ValidationRegex: /^(true|false)$/.source, ValidationMessage: "true or false only", }, + numericEnumMemberNames: { + Name: "numericEnumMemberNames", + Required: false, + DefaultValue: newSDK ? "words" : "legacy", + Description: + 'Member names for enum values that are only digits once a leading underscore is removed, such as `_1` or `_2024`. "words" spells the number out (`One`, `TwoThousandAndTwentyFour`); "legacy" (deprecated) keeps the numeric key (`1`, `2024`). Values with letters after the digits (`_1ST`) always become words.', + ValidationRegex: /^(words|legacy)$/.source, + ValidationMessage: "words or legacy only", + }, }; } diff --git a/templates/templates/typescriptv2/includes/sanitization.ts b/templates/templates/typescriptv2/includes/sanitization.ts index 34e5626f..9b320a8f 100644 --- a/templates/templates/typescriptv2/includes/sanitization.ts +++ b/templates/templates/typescriptv2/includes/sanitization.ts @@ -1141,7 +1141,7 @@ function getEnumName(value) { name = sanitizeName(name); name = caser().ToPascal(name); - if (/^[0-9]/.test(name)) { + if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name)) { name = caser().ToPascal(sanitizeName(name)); } return name; @@ -1149,6 +1149,13 @@ function getEnumName(value) { registerTemplateFunc("getEnumName", getEnumName); +function keepsNumericEnumMemberName(name: string): boolean { + return ( + context.Global.Config.NumericEnumMemberNames === "legacy" && + /^[0-9]+(E[0-9]+)?$/.test(name) + ); +} + function sanitizeZodName(name: string) { return sanitizeClassName(name) + "$"; } diff --git a/tests/config/review/typescriptv2/.speakeasy/gen.yaml b/tests/config/review/typescriptv2/.speakeasy/gen.yaml index 3acbceb8..70a6ccff 100644 --- a/tests/config/review/typescriptv2/.speakeasy/gen.yaml +++ b/tests/config/review/typescriptv2/.speakeasy/gen.yaml @@ -86,6 +86,7 @@ typescript: modelPropertyCasing: camel moduleFormat: dual multipartArrayFormat: standard + numericEnumMemberNames: legacy outputModelSuffix: output packageName: openapi preApplyUnionDiscriminators: true diff --git a/zSDKs/mcp-typescript/.speakeasy/gen.lock b/zSDKs/mcp-typescript/.speakeasy/gen.lock index 7c6134f7..b72c1920 100644 --- a/zSDKs/mcp-typescript/.speakeasy/gen.lock +++ b/zSDKs/mcp-typescript/.speakeasy/gen.lock @@ -6,7 +6,7 @@ management: speakeasyVersion: internal generationVersion: internal releaseVersion: 0.0.1 - configChecksum: 7f1f041501e08ba8657f7296ff840fca + configChecksum: f5747e95ee98dab53dac436151673342 repoURL: https://github.com/speakeasy-sdks/test-sdk installationURL: https://github.com/speakeasy-sdks/test-sdk features: diff --git a/zSDKs/mcp-typescript/.speakeasy/gen.yaml b/zSDKs/mcp-typescript/.speakeasy/gen.yaml index 2f0c8a2b..24f74cf9 100644 --- a/zSDKs/mcp-typescript/.speakeasy/gen.yaml +++ b/zSDKs/mcp-typescript/.speakeasy/gen.yaml @@ -65,6 +65,7 @@ mcp-typescript: title: overlayParam1 type: string multipartArrayFormat: standard + numericEnumMemberNames: legacy outputModelSuffix: output packageName: openapi responseFormat: envelope diff --git a/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock b/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock index c5264dbb..d52a7377 100644 --- a/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock +++ b/zSDKs/sdk-typescriptv2/.speakeasy/gen.lock @@ -6,7 +6,7 @@ management: speakeasyVersion: internal generationVersion: internal releaseVersion: 0.0.1 - configChecksum: d1039dff198a55d1d3e6d19e2e882fb7 + configChecksum: b9e582790d42dfeb89b905bb940389e2 repoURL: https://github.com/speakeasy-sdks/test-sdk installationURL: https://github.com/speakeasy-sdks/test-sdk features: diff --git a/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml b/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml index 3acbceb8..70a6ccff 100644 --- a/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml +++ b/zSDKs/sdk-typescriptv2/.speakeasy/gen.yaml @@ -86,6 +86,7 @@ typescript: modelPropertyCasing: camel moduleFormat: dual multipartArrayFormat: standard + numericEnumMemberNames: legacy outputModelSuffix: output packageName: openapi preApplyUnionDiscriminators: true From 05822d832720a83cd4ab45b97d1a546c2f5b4834 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Fri, 2 Oct 2026 17:30:56 -0400 Subject: [PATCH 06/13] fix(typescript): always spell out digits-only enum members in native enums A numeric member name such as `1 = "_1"` is rejected by the TypeScript compiler in a native enum, so the `legacy` setting of `numericEnumMemberNames` only ever produced a working member for union enums. The leading-digit rewrite now runs regardless of the option when the enum renders as `enumFormat: enum`, including per-type overrides via `x-speakeasy-enum-format`. The shared fragment gains a digits-only value so both forms are covered. --- .changesets/1790962042-cf634029.yaml | 2 +- templates/templates/typescriptv2/config.ts | 2 +- .../templates/typescriptv2/includes/enums.ts | 5 +++-- .../typescriptv2/includes/sanitization.ts | 16 ++++++++++------ .../uber/enum-leading-underscore-digit.yaml | 5 ++++- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index 9a5be793..d0ebdfe0 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -11,6 +11,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs, and the enum validator now reports values that collide once re-sanitized +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), and the enum validator now reports values that collide once re-sanitized author: AshGodfrey date: "2026-10-02" diff --git a/templates/templates/typescriptv2/config.ts b/templates/templates/typescriptv2/config.ts index 329148be..28263ac9 100644 --- a/templates/templates/typescriptv2/config.ts +++ b/templates/templates/typescriptv2/config.ts @@ -849,7 +849,7 @@ function getConfigFields( Required: false, DefaultValue: newSDK ? "words" : "legacy", Description: - 'Member names for enum values that are only digits once a leading underscore is removed, such as `_1` or `_2024`. "words" spells the number out (`One`, `TwoThousandAndTwentyFour`); "legacy" (deprecated) keeps the numeric key (`1`, `2024`). Values with letters after the digits (`_1ST`) always become words.', + 'Member names for enum values that are only digits once a leading underscore is removed, such as `_1` or `_2024`. "words" spells the number out (`One`, `TwoThousandAndTwentyFour`); "legacy" (deprecated) keeps the numeric key (`1`, `2024`) for union enums. Native enums (`enumFormat: enum`) and values with letters after the digits (`_1ST`) always become words.', ValidationRegex: /^(words|legacy)$/.source, ValidationMessage: "words or legacy only", }, diff --git a/templates/templates/typescriptv2/includes/enums.ts b/templates/templates/typescriptv2/includes/enums.ts index 1173ab2e..15983272 100644 --- a/templates/templates/typescriptv2/includes/enums.ts +++ b/templates/templates/typescriptv2/includes/enums.ts @@ -117,12 +117,13 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { // @ts-ignore function getEnumNames(t: TypeDef): string[] { + const format = t.Enum ? getEnumFormat(t) : "union"; if (t.Enum?.Names.length > 0) { if (context.Global.Config.FixEnumNameSanitization === true) { return t.Enum.Names.map((n) => sanitizeName(n.trim() || "Unknown")); } - return t.Enum.Names.map((n) => getEnumName(n)); + return t.Enum.Names.map((n) => getEnumName(n, format)); } else { - return getEnumNamesFromValues(t.Enum?.Values); + return getEnumNamesFromValues(t.Enum?.Values, format); } } diff --git a/templates/templates/typescriptv2/includes/sanitization.ts b/templates/templates/typescriptv2/includes/sanitization.ts index 9b320a8f..e6924859 100644 --- a/templates/templates/typescriptv2/includes/sanitization.ts +++ b/templates/templates/typescriptv2/includes/sanitization.ts @@ -1096,12 +1096,12 @@ function sanitizeMap(typeDef: TypeDef, usageLocation: string): string { } // @ts-ignore -function getEnumNamesFromValues(values) { +function getEnumNamesFromValues(values, format: "enum" | "union" = "union") { let enumNames = []; let names = {}; for (const value of values) { - let name = getEnumName(value); + let name = getEnumName(value, format); if (!names[name]) { names[name] = 0; } @@ -1111,7 +1111,7 @@ function getEnumNamesFromValues(values) { let seen = {}; for (const value of values) { - let name = getEnumName(value); + let name = getEnumName(value, format); if (names[name] > 1) { let candidate = `${name}${caser().ToPascal(getCasing(value))}`; if (seen[candidate]) { @@ -1131,7 +1131,7 @@ function getEnumNamesFromValues(values) { } // @ts-ignore -function getEnumName(value) { +function getEnumName(value, format: "enum" | "union" = "union") { let name = value.trim(); if (name === "") { @@ -1141,7 +1141,7 @@ function getEnumName(value) { name = sanitizeName(name); name = caser().ToPascal(name); - if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name)) { + if (/^[0-9]/.test(name) && !keepsNumericEnumMemberName(name, format)) { name = caser().ToPascal(sanitizeName(name)); } return name; @@ -1149,8 +1149,12 @@ function getEnumName(value) { registerTemplateFunc("getEnumName", getEnumName); -function keepsNumericEnumMemberName(name: string): boolean { +function keepsNumericEnumMemberName( + name: string, + format: "enum" | "union", +): boolean { return ( + format === "union" && context.Global.Config.NumericEnumMemberNames === "legacy" && /^[0-9]+(E[0-9]+)?$/.test(name) ); diff --git a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml index 363254f3..09dcbc9a 100644 --- a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml +++ b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml @@ -13,10 +13,12 @@ paths: operationId: getEnumLeadingUnderscoreDigit description: >- Returns an object whose enum property has values that start with an - underscore followed by digits (`_1ST`, `_2FA`, `_3D`), the shape + underscore followed by digits (`_1ST`, `_2FA`, `_3D`, `_10`), the shape protobuf-derived documents use for numeric-looking identifiers. Checks that every target derives a valid member name for such values instead of emitting a digit-leading identifier that fails to compile or format. + `_10` is digits-only, which TypeScript may keep as a numeric key in a + union enum but must spell out in a native enum. tags: - generation responses: @@ -40,3 +42,4 @@ components: - _1ST - _2FA - _3D + - _10 From 2c171f6953df1b0ba13f50f295e07ea6c3bb2ae1 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Mon, 5 Oct 2026 09:43:09 -0400 Subject: [PATCH 07/13] fix(java): derive valid names for digit-leading enum overrides and OAuth2 scopes An x-speakeasy-enums override or OAuth2 scope name that starts with an underscore followed by digits was cased to a digit-leading identifier (`_401K` became `401K`), which javac rejects. getEnumName now re-sanitizes a cased name that starts with a digit, matching the value-derived member path, so these produce `FourHundredAndOneK`. Names that already cased to a valid identifier are unchanged. --- .changesets/1790962042-cf634029.yaml | 3 ++- templates/templates/javav2/includes/sanitization.ts | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index d0ebdfe0..4f633e85 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -3,6 +3,7 @@ features: - enums targets: - csharp + - javav2 - mcp-typescript - php - pythonv2 @@ -11,6 +12,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), and the enum validator now reports values that collide once re-sanitized +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names, and the enum validator now reports values that collide once re-sanitized author: AshGodfrey date: "2026-10-02" diff --git a/templates/templates/javav2/includes/sanitization.ts b/templates/templates/javav2/includes/sanitization.ts index 15b54362..abdd8dcc 100644 --- a/templates/templates/javav2/includes/sanitization.ts +++ b/templates/templates/javav2/includes/sanitization.ts @@ -854,9 +854,12 @@ function getEnumName(value: string): string { name = "Unknown"; } - name = sanitizeName(name); + name = caser().ToPascal(sanitizeName(name)); + if (/^[0-9]/.test(name)) { + name = caser().ToPascal(sanitizeName(name)); + } - return caser().ToPascal(name); + return name; } // @ts-ignore From bb15dc83464333b9d2a8cdec2e2e4140b66e230e Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Mon, 5 Oct 2026 11:23:06 -0400 Subject: [PATCH 08/13] fix(validation): keep the enum collision validator's existing derivations Documents that pair an underscore-digit value with its plain form (`_1` next to `1`) generate today, and the templates give such pairs distinct member names. Reporting them as collisions turned working documents into validation errors, so the validator mirrors are restored to their previous derivations. --- .changesets/1790962042-cf634029.yaml | 2 +- .../validation/sanitization/sanitization.go | 15 ++------- internal/validation/validateenums_test.go | 32 ------------------- 3 files changed, 4 insertions(+), 45 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index 4f633e85..2893335e 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -12,6 +12,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names, and the enum validator now reports values that collide once re-sanitized +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names, and values that share a derived name receive unique suffixes author: AshGodfrey date: "2026-10-02" diff --git a/internal/validation/sanitization/sanitization.go b/internal/validation/sanitization/sanitization.go index d1a47463..1a74ec9b 100644 --- a/internal/validation/sanitization/sanitization.go +++ b/internal/validation/sanitization/sanitization.go @@ -219,7 +219,7 @@ func getEnumSanitizationMethods() *sequencedmap.Map[string, func(string) string] s = "Unknown" } - return resanitizeLeadingDigit(sanitization.SanitizeName(s), casing.New().ToPascal) + return casing.New().ToPascal(sanitization.SanitizeName(s)) }) sanitizationMethods.Set("go", func(s string) string { s = strings.TrimSpace(s) @@ -235,7 +235,7 @@ func getEnumSanitizationMethods() *sequencedmap.Map[string, func(string) string] s = "Unknown" } - return resanitizeLeadingDigit(sanitization.SanitizeName(s), casing.New().ToPascal) + return casing.New().ToPascal(sanitization.SanitizeName(s)) }) sanitizationMethods.Set("python", func(s string) string { s = strings.TrimSpace(s) @@ -243,21 +243,12 @@ func getEnumSanitizationMethods() *sequencedmap.Map[string, func(string) string] s = "unknown" } - return resanitizeLeadingDigit(sanitization.SanitizeName(s), casing.New().ToSNAKE) + return casing.New().ToSNAKE(sanitization.SanitizeName(s)) }) return sanitizationMethods } -func resanitizeLeadingDigit(name string, caseName func(string) string) string { - cased := caseName(name) - if cased == "" || cased[0] < '0' || cased[0] > '9' { - return cased - } - - return caseName(sanitization.SanitizeName(cased)) -} - func getEnumNameSanitizationMethods() *sequencedmap.Map[string, func(string, int) string] { baseMethods := getEnumSanitizationMethods() diff --git a/internal/validation/validateenums_test.go b/internal/validation/validateenums_test.go index a7533bca..98f2ac40 100644 --- a/internal/validation/validateenums_test.go +++ b/internal/validation/validateenums_test.go @@ -360,38 +360,6 @@ components: "validation error: [line 25:11] generator-validate-enums - enum value `EAN13` (`Ean13Upper`) will collide with `EAN_13` (`Ean13Upper`) [line `23`] when normalized, try using `x-speakeasy-enums`", }, }, - { - name: "underscore-digit enum values collide once re-sanitized", - args: args{ - schema: `openapi: 3.1.0 -info: - title: Test API - version: 1.0.0 -servers: - - url: http://localhost:8080 -paths: - /test: - get: - operationId: test - responses: - '200': - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/enum' -components: - schemas: - enum: - type: string - enum: - - _1 - - "1"`, - }, - wantErrs: []string{ - "validation error: [line 24:11] generator-validate-enums - enum value `1` (`OneUpper`) will collide with `_1` (`OneUpper`) [line `23`] when normalized, try using `x-speakeasy-enums`", - }, - }, { name: "duplicate enum values", args: args{ From 5738a8f2e1d5ab9822330a5b40d565c4d32cf479 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Mon, 5 Oct 2026 11:26:31 -0400 Subject: [PATCH 09/13] test(enums): cover an x-speakeasy-enums override in the underscore-digit fragment The fragment only exercised value-derived member names. C#, Unity and Java reach the leading-digit step only through an `x-speakeasy-enums` override, so the fragment now includes an enum whose override keeps the underscore-digit name. --- .../uber/enum-leading-underscore-digit.yaml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml index 09dcbc9a..5883a0a5 100644 --- a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml +++ b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml @@ -18,7 +18,9 @@ paths: that every target derives a valid member name for such values instead of emitting a digit-leading identifier that fails to compile or format. `_10` is digits-only, which TypeScript may keep as a numeric key in a - union enum but must spell out in a native enum. + union enum but must spell out in a native enum. A second property + supplies the same shape of name through `x-speakeasy-enums`, which + must also yield a valid member name. tags: - generation responses: @@ -33,6 +35,8 @@ paths: properties: accountType: $ref: "#/components/schemas/LeadingUnderscoreDigitEnum" + overriddenAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitOverrideEnum" components: schemas: LeadingUnderscoreDigitEnum: @@ -43,3 +47,11 @@ components: - _2FA - _3D - _10 + LeadingUnderscoreDigitOverrideEnum: + type: string + enum: + - UNSPECIFIED + - _1ST + x-speakeasy-enums: + - UNSPECIFIED + - _1ST From 8db3811346601cfdea72cf2ed9a6cdded53a9a0a Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Mon, 5 Oct 2026 13:53:06 -0400 Subject: [PATCH 10/13] fix(typescript,mcp): keep every valid numeric literal key under legacy enum member names The legacy check only matched plain digits or an uppercase E exponent, so values such as _1e5, _0x1F, _0b101 and _0o17, which already generate as valid numeric keys in a union enum, were renamed even under legacy. Match any numeric literal that strict mode accepts, and stop keeping leading-zero keys (_007, _08), which strict mode rejects. --- .../templates/mcp-typescript/includes/sanitization.ts | 9 ++++++++- .../templates/typescriptv2/includes/sanitization.ts | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/templates/templates/mcp-typescript/includes/sanitization.ts b/templates/templates/mcp-typescript/includes/sanitization.ts index 066bfff2..d8d9adc9 100644 --- a/templates/templates/mcp-typescript/includes/sanitization.ts +++ b/templates/templates/mcp-typescript/includes/sanitization.ts @@ -414,10 +414,17 @@ function getEnumName(value) { registerTemplateFunc("getEnumName", getEnumName); +// Matches a cased name that is a valid numeric literal property key in strict +// mode: a decimal integer with an optional exponent, or a hex, binary or octal +// literal. Leading zeros (`007`, `08`) are excluded because strict mode rejects +// them, so those values are spelled out even under `legacy`. +const numericLiteralKey = + /^(?:(?:0|[1-9][0-9]*)(?:[eE][0-9]+)?|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+)$/; + function keepsNumericEnumMemberName(name: string): boolean { return ( context.Global.Config.NumericEnumMemberNames === "legacy" && - /^[0-9]+(E[0-9]+)?$/.test(name) + numericLiteralKey.test(name) ); } diff --git a/templates/templates/typescriptv2/includes/sanitization.ts b/templates/templates/typescriptv2/includes/sanitization.ts index e6924859..755910ae 100644 --- a/templates/templates/typescriptv2/includes/sanitization.ts +++ b/templates/templates/typescriptv2/includes/sanitization.ts @@ -1149,6 +1149,13 @@ function getEnumName(value, format: "enum" | "union" = "union") { registerTemplateFunc("getEnumName", getEnumName); +// Matches a cased name that is a valid numeric literal property key in strict +// mode: a decimal integer with an optional exponent, or a hex, binary or octal +// literal. Leading zeros (`007`, `08`) are excluded because strict mode rejects +// them, so those values are spelled out even under `legacy`. +const numericLiteralKey = + /^(?:(?:0|[1-9][0-9]*)(?:[eE][0-9]+)?|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+)$/; + function keepsNumericEnumMemberName( name: string, format: "enum" | "union", @@ -1156,7 +1163,7 @@ function keepsNumericEnumMemberName( return ( format === "union" && context.Global.Config.NumericEnumMemberNames === "legacy" && - /^[0-9]+(E[0-9]+)?$/.test(name) + numericLiteralKey.test(name) ); } From b7413bb6c945b7637ca38932ce26be160278e38c Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Mon, 5 Oct 2026 15:02:27 -0400 Subject: [PATCH 11/13] fix(java): detect enum member collisions on the emitted upper-snake name getEnumNamesFromValues counted collisions on the Pascal name, so _1ST and 1ST (OneSt and OneST) looked distinct and were both snake-cased to ONE_ST, which javac rejects as a duplicate. Count on the member that is actually emitted so such pairs receive the casing suffix instead. Pairs that already deduplicated are unchanged. --- .changesets/1790962042-cf634029.yaml | 2 +- .../templates/javav2/includes/sanitization.ts | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index 2893335e..bf8e2a16 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -12,6 +12,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names, and values that share a derived name receive unique suffixes +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names and detects member collisions on the emitted upper-snake name, and values that share a derived name receive unique suffixes author: AshGodfrey date: "2026-10-02" diff --git a/templates/templates/javav2/includes/sanitization.ts b/templates/templates/javav2/includes/sanitization.ts index abdd8dcc..44106d54 100644 --- a/templates/templates/javav2/includes/sanitization.ts +++ b/templates/templates/javav2/includes/sanitization.ts @@ -775,21 +775,27 @@ function findFieldExample(example: any, field: FieldDef): any { function getEnumNamesFromValues(values: string[]): string[] { let enumNames = []; + // Count collisions on the emitted upper-snake member, not the intermediate + // Pascal name: `_1ST` and `1ST` derive `OneSt` and `OneST`, which differ, + // but both become `ONE_ST` once snake-cased. let names = {}; for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; + let member = sanitizeEnumMember(getEnumName(value)); + if (!names[member]) { + names[member] = 0; } - names[name] += 1; + names[member] += 1; } let seen = {}; for (const value of values) { let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; + let member = sanitizeEnumMember(name); + if (names[member] > 1) { + let candidate = sanitizeEnumMember( + `${name}${caser().ToPascal(getCasing(value))}`, + ); if (seen[candidate]) { let suffix = seen[candidate]; seen[candidate] += 1; @@ -797,10 +803,10 @@ function getEnumNamesFromValues(values: string[]): string[] { } else { seen[candidate] = 1; } - name = candidate; + member = candidate; } - enumNames.push(sanitizeEnumMember(name)); + enumNames.push(member); } return enumNames; From 5e444f879ad1f2ae47099065e3eca1c507d1142a Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Mon, 5 Oct 2026 15:28:21 -0400 Subject: [PATCH 12/13] test(enums): cover the _1ST + 1ST collision pair and fix it in the mockserver Add a third enum to the underscore-digit fragment that pairs _1ST with 1ST so CI exercises the collision path for every target. The mockserver counted collisions on the cased name (1St vs OneSt) before sanitizeFieldName collapsed both to OneSt and emitted a duplicate Go constant; count on the emitted field name and suffix the pair instead. --- .changesets/1790962042-cf634029.yaml | 3 +- .../mockserver/includes/sanitization.ts | 29 ++++++++++++++----- .../uber/enum-leading-underscore-digit.yaml | 14 ++++++++- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index bf8e2a16..b9b01814 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -5,6 +5,7 @@ targets: - csharp - javav2 - mcp-typescript + - mockserver - php - pythonv2 - ruby @@ -12,6 +13,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names and detects member collisions on the emitted upper-snake name, and values that share a derived name receive unique suffixes +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names and detects member collisions on the emitted upper-snake name, the mockserver detects them on the emitted field name, and values that share a derived name receive unique suffixes author: AshGodfrey date: "2026-10-02" diff --git a/templates/templates/mockserver/includes/sanitization.ts b/templates/templates/mockserver/includes/sanitization.ts index da62c604..72d82e37 100644 --- a/templates/templates/mockserver/includes/sanitization.ts +++ b/templates/templates/mockserver/includes/sanitization.ts @@ -752,23 +752,38 @@ function templateFloatValue( function getEnumNamesFromValues(values: string[]): string[] { let enumNames = []; + // Count collisions on the emitted field name, not the cased name: + // `_1ST` and `1ST` case to `1St` and `OneSt`, which differ, but both + // become `OneSt` once sanitizeFieldName rewrites the leading digit. let names = {}; for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; + let member = sanitizeFieldName(getEnumName(value)); + if (!names[member]) { + names[member] = 0; } - names[name] += 1; + names[member] += 1; } + let seen = {}; for (const value of values) { let name = getEnumName(value); - if (names[name] > 1) { - name = `${name}${caser().ToGoPascal(getCasing(value))}`; + let member = sanitizeFieldName(name); + if (names[member] > 1) { + let candidate = sanitizeFieldName( + `${name}${caser().ToGoPascal(getCasing(value))}`, + ); + if (seen[candidate]) { + let suffix = seen[candidate]; + seen[candidate] += 1; + candidate = `${candidate}${suffix}`; + } else { + seen[candidate] = 1; + } + member = candidate; } - enumNames.push(sanitizeFieldName(name)); + enumNames.push(member); } return enumNames; diff --git a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml index 5883a0a5..8decb1d6 100644 --- a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml +++ b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml @@ -20,7 +20,11 @@ paths: `_10` is digits-only, which TypeScript may keep as a numeric key in a union enum but must spell out in a native enum. A second property supplies the same shape of name through `x-speakeasy-enums`, which - must also yield a valid member name. + must also yield a valid member name. A third property pairs `_1ST` + with `1ST`: both derive `OneSt`-shaped names that differ only by + case, so targets that upper-snake their members (Java, Python, + Ruby) must detect the collision on the emitted member and suffix + it instead of declaring `ONE_ST` twice. tags: - generation responses: @@ -37,6 +41,8 @@ paths: $ref: "#/components/schemas/LeadingUnderscoreDigitEnum" overriddenAccountType: $ref: "#/components/schemas/LeadingUnderscoreDigitOverrideEnum" + collidingAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitCollisionEnum" components: schemas: LeadingUnderscoreDigitEnum: @@ -55,3 +61,9 @@ components: x-speakeasy-enums: - UNSPECIFIED - _1ST + LeadingUnderscoreDigitCollisionEnum: + type: string + enum: + - UNSPECIFIED + - _1ST + - 1ST From 510b6c672cc12fe34f6f0a8dabf5f154edbe6ae4 Mon Sep 17 00:00:00 2001 From: Ash Godfrey Date: Mon, 5 Oct 2026 21:43:49 -0400 Subject: [PATCH 13/13] fix(typescript,python,csharp,java,php,ruby,unity,mcp,mockserver): reserve every enum member name when suffixing collisions Values that derive the same member name receive a casing suffix, but the suffix was only checked against other suffixed candidates, not against members that needed no change. An enum listing `_1`, `1` and `OneUpper` therefore emitted `OneUpper` twice. Every target now reserves the untouched member names up front and keeps advancing the numeric suffix until the candidate is unused. `x-speakeasy-enums` override names bypassed collision handling entirely, so overrides such as `_1` and `One` emitted `One` twice once digits are spelled out. Overrides are now disambiguated as a group with the same rules as value-derived names; the explicit `fixEnumNameSanitization` path is unchanged. The shared fragment gains both shapes. --- .changesets/1790962042-cf634029.yaml | 2 +- .../templates/csharp/includes/sanitization.ts | 51 ++++++++-------- .../templates/csharp/includes/templating.ts | 2 +- templates/templates/javav2/includes/enums.ts | 2 +- .../templates/javav2/includes/sanitization.ts | 59 ++++++++++--------- .../mcp-typescript/includes/sanitization.ts | 2 +- .../mcp-typescript/includes/types.ts | 47 +++++++-------- .../mockserver/includes/sanitization.ts | 58 +++++++++--------- templates/templates/php/includes/enums.ts | 2 +- .../templates/php/includes/sanitization.ts | 47 +++++++-------- .../templates/pythonv2/includes/enums.ts | 2 +- .../pythonv2/includes/sanitization.ts | 47 +++++++-------- templates/templates/ruby/includes/enums.ts | 2 +- .../templates/ruby/includes/sanitization.ts | 47 +++++++-------- .../templates/typescriptv2/includes/enums.ts | 2 +- .../typescriptv2/includes/sanitization.ts | 47 +++++++-------- .../templates/unity/includes/sanitization.ts | 51 ++++++++-------- .../templates/unity/includes/templating.ts | 2 +- .../uber/enum-leading-underscore-digit.yaml | 28 ++++++++- 19 files changed, 248 insertions(+), 252 deletions(-) diff --git a/.changesets/1790962042-cf634029.yaml b/.changesets/1790962042-cf634029.yaml index b9b01814..a8f120ac 100644 --- a/.changesets/1790962042-cf634029.yaml +++ b/.changesets/1790962042-cf634029.yaml @@ -13,6 +13,6 @@ targets: - unity type: fix bump: patch -description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names and detects member collisions on the emitted upper-snake name, the mockserver detects them on the emitted field name, and values that share a derived name receive unique suffixes +description: derive valid enum member names for values that start with an underscore followed by digits; TypeScript and MCP TypeScript add a `numericEnumMemberNames` option that spells out digits-only values such as `_1` as `One` for new SDKs and keeps the numeric key for existing SDKs with union enums (native enums always spell digits out), Java applies the same derivation to `x-speakeasy-enums` overrides and OAuth2 scope names and detects member collisions on the emitted upper-snake name, the mockserver detects them on the emitted field name, values that share a derived name receive suffixes that are checked against every other member name in the enum, and `x-speakeasy-enums` override names are disambiguated as a group the same way author: AshGodfrey date: "2026-10-02" diff --git a/templates/templates/csharp/includes/sanitization.ts b/templates/templates/csharp/includes/sanitization.ts index 0b3cc08b..f95f2345 100644 --- a/templates/templates/csharp/includes/sanitization.ts +++ b/templates/templates/csharp/includes/sanitization.ts @@ -632,38 +632,35 @@ registerTemplateFunc("getEnumName", getEnumName); // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - const enumNames = []; - - const names = {}; - if (!values) return enumNames; - values.forEach((value) => { - const name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + if (!values) return []; + return disambiguateEnumNames(values, getEnumName).map((name) => + sanitizeFieldName(name), + ); +} - names[name] += 1; - }); +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); + } - const seen = {}; - values.forEach((value) => { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(sanitizeFieldName(name)); + return name; }); - - return enumNames; } registerTemplateFunc("getEnumNamesFromValues", getEnumNamesFromValues); diff --git a/templates/templates/csharp/includes/templating.ts b/templates/templates/csharp/includes/templating.ts index eb11e611..98156d84 100644 --- a/templates/templates/csharp/includes/templating.ts +++ b/templates/templates/csharp/includes/templating.ts @@ -859,7 +859,7 @@ function templateFloatValue( function getEnumNames(t: TypeDef): string[] { let names; if (t.Enum?.Names.length > 0) { - names = t.Enum.Names.map((n) => getEnumName(n)); + names = disambiguateEnumNames(t.Enum.Names, getEnumName); } else { names = getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/javav2/includes/enums.ts b/templates/templates/javav2/includes/enums.ts index ab74ce56..1bd83843 100644 --- a/templates/templates/javav2/includes/enums.ts +++ b/templates/templates/javav2/includes/enums.ts @@ -70,7 +70,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return disambiguateEnumNames(t.Enum.Names, getEnumName); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/javav2/includes/sanitization.ts b/templates/templates/javav2/includes/sanitization.ts index 44106d54..312a2cd4 100644 --- a/templates/templates/javav2/includes/sanitization.ts +++ b/templates/templates/javav2/includes/sanitization.ts @@ -773,43 +773,44 @@ function findFieldExample(example: any, field: FieldDef): any { // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - // Count collisions on the emitted upper-snake member, not the intermediate // Pascal name: `_1ST` and `1ST` derive `OneSt` and `OneST`, which differ, // but both become `ONE_ST` once snake-cased. - let names = {}; - for (const value of values) { - let member = sanitizeEnumMember(getEnumName(value)); - if (!names[member]) { - names[member] = 0; - } + return disambiguateEnumNames( + values, + (value) => sanitizeEnumMember(getEnumName(value)), + (value) => + sanitizeEnumMember( + `${getEnumName(value)}${caser().ToPascal(getCasing(value))}`, + ), + ); +} - names[member] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, + deriveCandidate: (value: string, name: string) => string = (value, name) => + `${name}${caser().ToPascal(getCasing(value))}`, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - let member = sanitizeEnumMember(name); - if (names[member] > 1) { - let candidate = sanitizeEnumMember( - `${name}${caser().ToPascal(getCasing(value))}`, - ); - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = deriveCandidate(value, name); + name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; } - member = candidate; + used.add(name); } - - enumNames.push(member); - } - - return enumNames; + return name; + }); } // Java-specific scope dedup that produces PascalCase names diff --git a/templates/templates/mcp-typescript/includes/sanitization.ts b/templates/templates/mcp-typescript/includes/sanitization.ts index d8d9adc9..6ff8a62a 100644 --- a/templates/templates/mcp-typescript/includes/sanitization.ts +++ b/templates/templates/mcp-typescript/includes/sanitization.ts @@ -434,7 +434,7 @@ function getEnumNames(t: TypeDef): string[] { if (context.Global.Config.FixEnumNameSanitization === true) { return t.Enum.Names.map((n) => sanitizeName(n.trim() || "Unknown")); } - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } return getEnumNamesFromValues(t.Enum?.Values); diff --git a/templates/templates/mcp-typescript/includes/types.ts b/templates/templates/mcp-typescript/includes/types.ts index a600552e..5b8f8625 100644 --- a/templates/templates/mcp-typescript/includes/types.ts +++ b/templates/templates/mcp-typescript/includes/types.ts @@ -116,37 +116,32 @@ function enumLiteralFromValue( } function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = sanitizeEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, sanitizeEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = sanitizeEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } function getEnumDataType(typeDef: TypeDef): "string" | "number" { diff --git a/templates/templates/mockserver/includes/sanitization.ts b/templates/templates/mockserver/includes/sanitization.ts index 72d82e37..aa707bbc 100644 --- a/templates/templates/mockserver/includes/sanitization.ts +++ b/templates/templates/mockserver/includes/sanitization.ts @@ -750,43 +750,43 @@ function templateFloatValue( // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - // Count collisions on the emitted field name, not the cased name: // `_1ST` and `1ST` case to `1St` and `OneSt`, which differ, but both // become `OneSt` once sanitizeFieldName rewrites the leading digit. - let names = {}; - for (const value of values) { - let member = sanitizeFieldName(getEnumName(value)); - if (!names[member]) { - names[member] = 0; - } + return disambiguateEnumNames( + values, + (value) => sanitizeFieldName(getEnumName(value)), + (value) => + sanitizeFieldName( + `${getEnumName(value)}${caser().ToGoPascal(getCasing(value))}`, + ), + ); +} - names[member] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, + deriveCandidate: (value: string, name: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - let member = sanitizeFieldName(name); - if (names[member] > 1) { - let candidate = sanitizeFieldName( - `${name}${caser().ToGoPascal(getCasing(value))}`, - ); - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = deriveCandidate(value, name); + name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; } - member = candidate; + used.add(name); } - - enumNames.push(member); - } - - return enumNames; + return name; + }); } // @ts-ignore diff --git a/templates/templates/php/includes/enums.ts b/templates/templates/php/includes/enums.ts index eb224361..0a539d07 100644 --- a/templates/templates/php/includes/enums.ts +++ b/templates/templates/php/includes/enums.ts @@ -55,7 +55,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } else { return getEnumNamesFromValues(t.Enum.Values); } diff --git a/templates/templates/php/includes/sanitization.ts b/templates/templates/php/includes/sanitization.ts index 9a962a7d..9526ded0 100644 --- a/templates/templates/php/includes/sanitization.ts +++ b/templates/templates/php/includes/sanitization.ts @@ -622,37 +622,32 @@ function templateFloatValue( // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, getEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore diff --git a/templates/templates/pythonv2/includes/enums.ts b/templates/templates/pythonv2/includes/enums.ts index 7f00c5dd..353b9f71 100644 --- a/templates/templates/pythonv2/includes/enums.ts +++ b/templates/templates/pythonv2/includes/enums.ts @@ -71,7 +71,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/pythonv2/includes/sanitization.ts b/templates/templates/pythonv2/includes/sanitization.ts index cab4c0f2..74309517 100644 --- a/templates/templates/pythonv2/includes/sanitization.ts +++ b/templates/templates/pythonv2/includes/sanitization.ts @@ -2297,37 +2297,32 @@ function templateOptionalSymbol() { // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, getEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}_${getCasing(value).toUpperCase()}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}_${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}_${getCasing(value).toUpperCase()}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}_${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore diff --git a/templates/templates/ruby/includes/enums.ts b/templates/templates/ruby/includes/enums.ts index 04398001..cb9b73d0 100644 --- a/templates/templates/ruby/includes/enums.ts +++ b/templates/templates/ruby/includes/enums.ts @@ -48,7 +48,7 @@ function sanitizeEnumValue(value: any, type: GojaEnum): string { // @ts-ignore function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return getEnumNamesFromValues(t.Enum.Names); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/templates/templates/ruby/includes/sanitization.ts b/templates/templates/ruby/includes/sanitization.ts index 8a816407..0ae07b59 100644 --- a/templates/templates/ruby/includes/sanitization.ts +++ b/templates/templates/ruby/includes/sanitization.ts @@ -786,37 +786,32 @@ function templateOptionalSymbol(): string { // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, getEnumName); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}_${getCasing(value).toUpperCase()}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}_${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}_${getCasing(value).toUpperCase()}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}_${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore diff --git a/templates/templates/typescriptv2/includes/enums.ts b/templates/templates/typescriptv2/includes/enums.ts index 15983272..2de597bf 100644 --- a/templates/templates/typescriptv2/includes/enums.ts +++ b/templates/templates/typescriptv2/includes/enums.ts @@ -122,7 +122,7 @@ function getEnumNames(t: TypeDef): string[] { if (context.Global.Config.FixEnumNameSanitization === true) { return t.Enum.Names.map((n) => sanitizeName(n.trim() || "Unknown")); } - return t.Enum.Names.map((n) => getEnumName(n, format)); + return getEnumNamesFromValues(t.Enum.Names, format); } else { return getEnumNamesFromValues(t.Enum?.Values, format); } diff --git a/templates/templates/typescriptv2/includes/sanitization.ts b/templates/templates/typescriptv2/includes/sanitization.ts index 755910ae..b9a0340e 100644 --- a/templates/templates/typescriptv2/includes/sanitization.ts +++ b/templates/templates/typescriptv2/includes/sanitization.ts @@ -1097,37 +1097,32 @@ function sanitizeMap(typeDef: TypeDef, usageLocation: string): string { // @ts-ignore function getEnumNamesFromValues(values, format: "enum" | "union" = "union") { - let enumNames = []; - - let names = {}; - for (const value of values) { - let name = getEnumName(value, format); - if (!names[name]) { - names[name] = 0; - } + return disambiguateEnumNames(values, (value) => getEnumName(value, format)); +} - names[name] += 1; +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); } - let seen = {}; - for (const value of values) { - let name = getEnumName(value, format); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - let suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(name); - } - - return enumNames; + return name; + }); } // @ts-ignore diff --git a/templates/templates/unity/includes/sanitization.ts b/templates/templates/unity/includes/sanitization.ts index a6c7cfe8..bca3f0e7 100644 --- a/templates/templates/unity/includes/sanitization.ts +++ b/templates/templates/unity/includes/sanitization.ts @@ -383,38 +383,35 @@ registerTemplateFunc("getEnumName", getEnumName); // @ts-ignore function getEnumNamesFromValues(values: string[]): string[] { - const enumNames = []; - - const names = {}; - if (!values) return enumNames; - values.forEach((value) => { - const name = getEnumName(value); - if (!names[name]) { - names[name] = 0; - } + if (!values) return []; + return disambiguateEnumNames(values, getEnumName).map((name) => + sanitizeFieldName(name), + ); +} - names[name] += 1; - }); +function disambiguateEnumNames( + values: string[], + deriveName: (value: string) => string, +): string[] { + const baseNames = values.map(deriveName); + const counts = new Map(); + for (const name of baseNames) { + counts.set(name, (counts.get(name) || 0) + 1); + } - const seen = {}; - values.forEach((value) => { - let name = getEnumName(value); - if (names[name] > 1) { - let candidate = `${name}${caser().ToPascal(getCasing(value))}`; - if (seen[candidate]) { - const suffix = seen[candidate]; - seen[candidate] += 1; - candidate = `${candidate}${suffix}`; - } else { - seen[candidate] = 1; - } + const used = new Set(baseNames.filter((name) => counts.get(name) === 1)); + return values.map((value, i) => { + let name = baseNames[i]; + if (counts.get(name) > 1) { + const candidate = `${name}${caser().ToPascal(getCasing(value))}`; name = candidate; + for (let suffix = 1; used.has(name); suffix++) { + name = `${candidate}${suffix}`; + } + used.add(name); } - - enumNames.push(sanitizeFieldName(name)); + return name; }); - - return enumNames; } registerTemplateFunc("getEnumNamesFromValues", getEnumNamesFromValues); diff --git a/templates/templates/unity/includes/templating.ts b/templates/templates/unity/includes/templating.ts index 9c3c1dfc..5ecf4ae4 100644 --- a/templates/templates/unity/includes/templating.ts +++ b/templates/templates/unity/includes/templating.ts @@ -464,7 +464,7 @@ function templateFloatValue( // @ts-ignore function getEnumNames(t: TypeDef): string[] { if (t.Enum?.Names.length > 0) { - return t.Enum.Names.map((n) => getEnumName(n)); + return disambiguateEnumNames(t.Enum.Names, getEnumName); } else { return getEnumNamesFromValues(t.Enum?.Values); } diff --git a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml index 8decb1d6..aa956379 100644 --- a/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml +++ b/tests/specs/fragments/uber/enum-leading-underscore-digit.yaml @@ -24,7 +24,15 @@ paths: with `1ST`: both derive `OneSt`-shaped names that differ only by case, so targets that upper-snake their members (Java, Python, Ruby) must detect the collision on the emitted member and suffix - it instead of declaring `ONE_ST` twice. + it instead of declaring `ONE_ST` twice. A fourth property lists + `_1`, `1` and `OneUpper`: the first two derive the same name and + receive a casing suffix, which lands on `OneUpper`, the untouched + third member. Targets must reserve every emitted member name and + keep advancing the numeric suffix until the candidate is unused. A + fifth property supplies `_1` and `One` through `x-speakeasy-enums`, + which derive the same name once the digit is spelled out, so + override names must be disambiguated as a group the same way values + are. tags: - generation responses: @@ -43,6 +51,10 @@ paths: $ref: "#/components/schemas/LeadingUnderscoreDigitOverrideEnum" collidingAccountType: $ref: "#/components/schemas/LeadingUnderscoreDigitCollisionEnum" + suffixCollidingAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitSuffixCollisionEnum" + overrideCollidingAccountType: + $ref: "#/components/schemas/LeadingUnderscoreDigitOverrideCollisionEnum" components: schemas: LeadingUnderscoreDigitEnum: @@ -67,3 +79,17 @@ components: - UNSPECIFIED - _1ST - 1ST + LeadingUnderscoreDigitSuffixCollisionEnum: + type: string + enum: + - _1 + - "1" + - OneUpper + LeadingUnderscoreDigitOverrideCollisionEnum: + type: string + enum: + - first + - second + x-speakeasy-enums: + - _1 + - One