fix(type-name-formatter): dedupe colliding identifiers via upfront precommit#1726
Open
mlewando-cp wants to merge 2 commits intoacacode:mainfrom
Open
fix(type-name-formatter): dedupe colliding identifiers via upfront precommit#1726mlewando-cp wants to merge 2 commits intoacacode:mainfrom
mlewando-cp wants to merge 2 commits intoacacode:mainfrom
Conversation
…ecommit
Two OpenAPI schemas whose raw names differ only in separator placement
(e.g. `Foo_Bar` and `FooBar`) both normalize to `FooBar` via the existing
`startCase` + whitespace-strip pipeline. The previous cache, keyed by the
raw input, never detected the collision, producing two `export interface
FooBar` declarations and TS2717 whenever the shapes differed.
`precommit(rawNames)` resolves the full schema list in two passes before
the parser runs:
1. Canonical raw names (raw === formatted output) claim their slot.
User-declared identifiers like `FooBar` and `FooBar1` are preserved
regardless of source order.
2. Non-canonical raw names suffix-until-free past every already-claimed
identifier, producing `FooBar`, `FooBar1`, `FooBar2`, ... deterministically.
`format()` shrinks to a pure cache lookup with a no-collision-logic
fallback for dynamically discovered names (enum keys, extracted schemas).
Integrating resolution upfront is necessary because the generator stashes
`format()` return values into inline type strings during Phase 1 of
schema/route parsing — any mid-flight rename would leave those captures
stale.
Enum keys are intentionally excluded from collision tracking: they are
scoped per-enum block (`EnumA.Bar` and `EnumB.Bar` are both valid).
Collision detection runs after `onFormatTypeName`, so hooks can't
accidentally reintroduce duplicates either.
Adds seven regression cases under tests/spec/name-collision/: basic
collision, pre-existing numeric suffix in natural and reversed source
order, multiple canonicals sharing a base, hook interaction,
repeat-reference sanity check, and route-reference correctness (a
client-generating test that catches any stale inline type string).
Fixes acacode#1724
🦋 Changeset detectedLatest commit: 53ca830 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two OpenAPI schema keys that differ only in separator placement — e.g.
Foo_BarandFooBar— both normalize to the same TypeScript identifier (FooBar) viasrc/type-name-formatter.ts'sstartCase+ whitespace-strip pipeline. The previous cache is keyed by the raw input (hashKey = prefix_name_suffix), not by the formatted output, so collisions go undetected and the generator emits twoexport interface FooBar { … }declarations — TypeScript declaration-merges them and reportsTS2717whenever the shapes differ.This PR resolves collisions upfront, in a new
precommit(rawNames)pass the generator invokes once after loading schema components and before schema parsing begins.format()shrinks to a pure cache lookup plus a no-collision-logic fallback for dynamically-discovered names (enum keys,extractEnums/extractResponsesresults). All formatting logic is concentrated in a single privatecomputeFormattedName()helper soformat(),precommit(), and the newdisableFormatTypeNames/typeNameSeparatoroptions share one pipeline.Fixes #1724.
Why not resolve collisions in
format()?An earlier iteration of this PR tried a mid-flight "evict-and-rename" approach inside
format(): when a canonical incoming name collides with a non-canonical incumbent, move the incumbent to a suffixed slot so the user-declared name survives. It corrupts downstream output.The schema parser calls
format()during Phase 1 (fromschema-utils.ts,schema-parser.ts,schema-routes.ts) and captures the return value directly into inline type strings — e.g.this.request<FooBar, any>for a route response. Later Phase-2 work (prepareModelType→collectModelTypes) callsformat()again per schema and captures the result intopreparedModelType.name. Phase 2 happens after Phase 1, so definitions always see the final cache state and come out consistent. But any eviction that renames a raw schema mid-flight leaves the earlier Phase-1 captures frozen at the old name, and a route that returnsFoo_Barcan end up generic-parameterized onFooBar— which is now the shape of an entirely different schema. A route-reference regression test in this PR would have caught it.Pre-committing every schema name before the parser runs sidesteps the problem entirely: every
format()call returns the final name on the first shot.Design
Two-pass resolution in
precommitrawName === computeFormattedName(rawName)(i.e. the name already matches its formatted output), reserve that identifier. User-declared names likeFooBar,FooBar1,User,V1Responseall survive regardless of source order.Foo_Bar→FooBar), append the smallest integer suffix not already used by any pre-committed canonical or earlier non-canonical, producing deterministicFooBar,FooBar1,FooBar2, …format()becomes a lookupSingle source of truth for the pipeline
computeFormattedNameis the only place startCase,disableFormatTypeNames,typeNameSeparator, the ALL_CAPS fast path, and theonFormatTypeNamehook are applied.precommitand theformat()fallback both go through it, so all three formatting options (this PR's collision resolution +disableFormatTypeNames+typeNameSeparator) compose without special-casing.Enum keys are intentionally excluded
Enum keys live in their own per-enum scope (
EnumA.BarandEnumB.Barare both valid TS). Cross-enum collisions don't need disambiguation, soprecommitandusedFormattedTypeNamesonly trackschemaType === "type-name". Enum keys always hit the fallback path informat(), which preserves existing behavior.Hook interaction
Collision detection runs on the hook output, not the raw
startCaseoutput. IfonFormatTypeNamemaps two distinct raw names to the same string, one still ends up suffixed.Minimal repro (pre-fix)
Before: two
export interface FooBardeclarations, TS2717 once@ts-nocheckis stripped. After:export interface FooBar(for rawFooBar, withkind: "modern") andexport interface FooBar1(for rawFoo_Bar, withkind: "legacy"); route references are consistent.Tests
Seven new cases under
tests/spec/name-collision/:Foo_Bar+FooBar→FooBar+FooBar1; bothkindliterals preservedFoo_Bar,FooBar,FooBar1in that order →FooBar,FooBar1,FooBar2; noFooBar11FooBar,Foo_Bar,FooBar1,FooBar2→ non-canonical lands atFooBar3, canonicals preservedonFormatTypeName: () => "Shared"→Shared+Shared1Dogreferenced from three endpoints → exactly oneexport interface DoggenerateClient: true, each route's generic parameter matches the corresponding definition by its unique property. Catches the eviction-era regression described above.Context
Our client is generated from a Scala backend's OpenAPI spec. Scala's generator naturally emits both underscore-wrapped names (parameterized discriminator variants) and plain names (flat schemas), so having
Foo_BarandFooBarside-by-side is idiomatic output, not a schema authoring mistake. The numeric suffix convention (Foo_Bar1in some specs) comes from the Scala generator itself, not fromswagger-typescript-api— the suffix loop in this PR skips past it correctly.Historical context: #65 introduced the
startCasenormalization that underpins the collision. This PR doesn't change that normalization; it adds collision-aware resolution on top of it.Test plan
bun run test→ 198 passing (189 pre-existing + 7 name-collision + 2 for the newdisableFormatTypeNames/typeNameSeparatorsuites still green on top of this PR)bun run buildcleanbun run format:checkcleanbunx biome checkon modified files clean