Skip to content

fix(typescript,python,csharp,java,php,ruby,unity,mcp): derive valid member names for underscore-digit enum values - #70

Open
AshGodfrey wants to merge 13 commits into
mainfrom
ash/enum-leading-underscore-digit-names
Open

AshGodfrey wants to merge 13 commits into
mainfrom
ash/enum-leading-underscore-digit-names

Conversation

@AshGodfrey

@AshGodfrey AshGodfrey commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Why

Enum values that start with an underscore followed by digits (_1ST, _2FA, _401K) made generation fail in most targets. Protobuf-derived documents use this shape for numeric-looking identifiers.

The shared sanitizer only spells out a leading number when the first character is a digit, so _1ST passes through unchanged. Each target's caser then drops the underscore and emits 1ST, which is not a valid identifier.

What changed

Each affected target now re-sanitizes a cased member name that starts with a digit. For the value _1ST:

Target Before Now
TypeScript, MCP TypeScript 1St, generation aborts OneSt
Python (enumFormat: enum) 1_ST, generation aborts ONE_ST
PHP 1St, PHPStan fails OneSt
Ruby 1_ST, compile fails ONE_ST
C#, Unity, Java worked for values, failed for x-speakeasy-enums overrides (and Java OAuth2 scope names) valid on every path
Go, Python (enumFormat: union) worked unchanged

Two related fixes:

  • Overrides. x-speakeasy-enums names go through the same derivation, so an override of _401K now generates as FourHundredAndOneK. fixEnumNameSanitization still returns overrides verbatim.
  • Collisions. When two values derive the same member (_1 and 1, or _1ST and 1ST), the members are suffixed (OneUpper / OneUpper1). TypeScript, MCP TypeScript and Unity gain the numeric suffix that C#, PHP, Python and Ruby already had. Java and the mockserver now detect collisions on the name they emit, where previously they compared an intermediate name and declared the same member twice.

New option: numericEnumMemberNames (TypeScript, MCP TypeScript)

A digits-only value such as _1 already works in a TypeScript union enum, because 1: "_1" is a valid key. Renaming it would change working output, so that case is behind an option:

  • words (default for new SDKs): _1 becomes One.
  • legacy (default for existing SDKs): the numeric key is kept. This covers any numeric literal that strict mode accepts, including 1e5 and 0x1F.

Values that never produced a valid member are always spelled out: anything with letters after the digits (_1ST), leading-zero keys (_007), and all digits-only values in native enums (enumFormat: enum).

Impact on existing SDKs

  • Member names that were already valid are unchanged.
  • Existing TypeScript and MCP TypeScript SDKs get numericEnumMemberNames: legacy written to gen.yaml and keep their numeric keys.
  • Every other change applies to output that previously failed to generate or compile.

Testing

  • Added tests/specs/fragments/uber/enum-leading-underscore-digit.yaml, which covers _1ST, _2FA, _3D, the digits-only _10, an x-speakeasy-enums override of _1ST, and the colliding pair _1ST + 1ST. It is generated and compiled for every target in CI; the secondary variant covers enumFormat: enum for TypeScript and Python.
  • Generated every affected target from probe documents with builds from main and from this branch. main fails in TypeScript, MCP TypeScript, PHP, Ruby and Python (enumFormat: enum); this branch produces valid members in all of them, and the Java build compiles with overrides and OAuth2 scopes.
  • Checked numericEnumMemberNames by hand on TypeScript and MCP TypeScript: a new SDK emits One / Ten, an existing SDK emits 1 / 10 exactly as main does, and native enums always emit words. tsc --noEmit passes on each.
  • Confirmed that pairs which already deduplicated in Java (foo_bar / FooBar / FOO_BAR) produce byte-identical output.
  • make check-template-<target>, make lint, prettier and go test ./internal/validation/... pass.

Out of scope

Enum member naming is implemented separately in each target's templates, so this fix had to be applied target by target and the collision handling differed between them. internal/namer has an enum namer, but it only covers Go, the mockserver and Terraform, and only predicts names for conflict detection. generator-validate-enums also derives names on its own, so it can disagree with a target about whether two values collide.

Moving member derivation and collision handling into one shared namer, used by the targets and the validator, would remove this duplication. That is a refactor with output implications for every target, so it is left for separate investigation.

Public-safety check

  • This change contains no credentials, customer documents, private repository URLs, private filesystem paths, or unredacted private logs.
  • Title, body, comments, and commit messages name no customers or customer-derived identifiers, private paths or trackers, or workflow provenance, and are understandable without private context (.claude/skills/public-repo-communication/SKILL.md).
  • Generated fixtures and review SDK changes are public-safe.
  • I reviewed git diff --check.

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.
@AshGodfrey
AshGodfrey requested a review from a team as a code owner October 2, 2026 17:35
@AshGodfrey
AshGodfrey requested a review from 2ynn October 2, 2026 17:35
@github-actions github-actions Bot added csharp Trigger (24) snapshot tests for csharp mcp-typescript Trigger (16) snapshot tests for mcp-typescript php Trigger (22) snapshot tests for php python Trigger (106) snapshot tests for python ruby Trigger (19) snapshot tests for ruby typescript Trigger (168) snapshot tests for typescript unity labels Oct 2, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 9 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes enum member name generation for underscore-digit values across seven SDK targets by re-sanitizing digit-leading cased names, with a new test fragment covering the failing inputs. Existing valid names are unchanged, making this a focused, clearly beneficial bug fix.

Re-trigger cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes enum member name generation across seven SDK targets for underscore-digit values like _1ST by re-sanitizing digit-leading cased names, with a new test fragment pinning the corrected behavior. Existing valid names are unchanged, making this a focused, clearly beneficial bug fix.

Re-trigger cubic

@speakeasy-generator-bot

speakeasy-generator-bot Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Generator Snapshot Testing

Selector(s) Result Summary
snapshot-csharp, snapshot-mcp-typescript, snapshot-php, snapshot-python, snapshot-ruby, snapshot-typescript ✅ Snapshot run completed

Snapshot results: https://github.com/speakeasy-api/openapi-generation-snapshots/issues/107#issuecomment-6007671653

Snapshot results are summarized above. Additional run context is linked for maintainers with access.

@AshGodfrey
AshGodfrey marked this pull request as draft October 2, 2026 18:33
…ame 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`.
…n 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.
…y 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.
@AshGodfrey
AshGodfrey marked this pull request as ready for review October 2, 2026 20:31

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 19 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Focused bug fix: re-sanitizes underscore-digit enum values into valid member names across affected targets, adds collision validation, and preserves existing SDK output via the backward-compatible numericEnumMemberNames option.

Re-trigger cubic

…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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 5 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Focused bug fix: re-sanitizes underscore-digit enum values into valid member names across affected codegen targets, adds validator collision detection, and preserves existing SDK output via the new backward-compatible numericEnumMemberNames option.

Re-trigger cubic

…uth2 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.
@AshGodfrey AshGodfrey changed the title fix(typescript,python,csharp,php,ruby,unity,mcp): derive valid member names for underscore-digit enum values fix(typescript,python,csharp,java,php,ruby,unity,mcp): derive valid member names for underscore-digit enum values Oct 5, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread templates/templates/javav2/includes/sanitization.ts
…ions

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 3 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

…git 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes enum member name derivation across generators for underscore-digit values, adds a legacy config option that preserves existing TypeScript/MCP union output, and adds a regression test. Bounded bug fix with the risky behavior change gated behind legacy defaults.

Re-trigger cubic

…y 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Shadow auto-approve: would auto-approve. Fixes enum member name derivation for underscore-digit values across generators, with a numericEnumMemberNames option preserving legacy TypeScript/MCP union output and a regression test. Bounded bug fix with existing SDK output kept by default.

Re-trigger cubic

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread templates/templates/javav2/includes/sanitization.ts Outdated
…ckserver

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@TristanSpeakEasy TristanSpeakEasy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good Ash, left two non-blocking comments on collisions introduced by the new words default. Both produce duplicate keys in TypeScript and MCP TypeScript, so worth sorting before you merge. CI still needs to finish cleanly too.

if (names[name] > 1) {
name = `${name}${caser().ToPascal(getCasing(value))}`;
let candidate = `${name}${caser().ToPascal(getCasing(value))}`;
if (seen[candidate]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking, but the new words default makes a previously valid enum [_1, "1", OneUpper] emit OneUpper, OneUpper1, OneUpper, which TypeScript rejects as duplicate properties. I generated this on both branches: main emits distinct keys 1, One, OneUpper. seen only tracks collision candidates, so it doesn't reserve the untouched OneUpper member. Can you reserve all member names and keep advancing the suffix until the final candidate is unused? The same case fails in MCP TypeScript too, and would be worth adding to the fragment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Every target now reserves the member names that need no change up front and keeps advancing the numeric suffix until the candidate is unused, so [_1, "1", OneUpper] emits OneUpper1, OneUpper2, OneUpper. Legacy mode is unchanged (1, One, OneUpper). The fragment now carries this enum.

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));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a separate collision on the override path: with enum: [first, second] and x-speakeasy-enums: [_1, One], the new words default emits One twice. Main emits distinct keys 1 and One, but this map bypasses the collision handling, so the PR output no longer compiles. I reproduced this in both TypeScript and MCP TypeScript. Can you disambiguate the transformed overrides as a group too, while keeping the explicit fixEnumNameSanitization behaviour unchanged? Not blocking, just another case to cover alongside the value-derived names.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Override names now go through the same group disambiguation as value-derived names in every target, so x-speakeasy-enums: [_1, One] emits OneUpper and OneMixed. The explicit fixEnumNameSanitization path is untouched, and legacy mode still emits 1 and One. The fragment now carries this enum too.

…erve 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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 19 files (changes from recent commits).

Confidence score: 3/5

  • In templates/templates/mcp-typescript/includes/types.ts, legacy mode can emit an invalid member when values share a preserved numeric name, making the generated TypeScript invalid. Re-sanitize the candidate before adding the collision suffix, so 1Upper becomes a valid name such as OneUpper.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="templates/templates/mcp-typescript/includes/types.ts">

<violation number="1" location="templates/templates/mcp-typescript/includes/types.ts:136">
P1: Legacy mode still generates invalid members when two values share a preserved numeric name. Re-sanitize the suffixed candidate so `1Upper` becomes a valid name such as `OneUpper` before collision suffixing.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

return values.map((value, i) => {
let name = baseNames[i];
if (counts.get(name) > 1) {
const candidate = `${name}${caser().ToPascal(getCasing(value))}`;

@cubic-dev-ai cubic-dev-ai Bot Oct 6, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Legacy mode still generates invalid members when two values share a preserved numeric name. Re-sanitize the suffixed candidate so 1Upper becomes a valid name such as OneUpper before collision suffixing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At templates/templates/mcp-typescript/includes/types.ts, line 136:

<comment>Legacy mode still generates invalid members when two values share a preserved numeric name. Re-sanitize the suffixed candidate so `1Upper` becomes a valid name such as `OneUpper` before collision suffixing.</comment>

<file context>
@@ -116,37 +116,32 @@ function enumLiteralFromValue(
+  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++) {
</file context>
Suggested change
const candidate = `${name}${caser().ToPascal(getCasing(value))}`;
const candidate = sanitizeEnumName(`${name}${caser().ToPascal(getCasing(value))}`);
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

csharp Trigger (24) snapshot tests for csharp mcp-typescript Trigger (16) snapshot tests for mcp-typescript php Trigger (22) snapshot tests for php python Trigger (106) snapshot tests for python ruby Trigger (19) snapshot tests for ruby typescript Trigger (168) snapshot tests for typescript unity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants