Skip to content

fix(http-client-csharp): avoid swapping model factory parameter names when previous contract had same-typed params in different order#10491

Merged
JoshLove-msft merged 3 commits intomicrosoft:mainfrom
JoshLove-msft:csharp/fix-param-preserve-non-paging
Apr 24, 2026
Merged

fix(http-client-csharp): avoid swapping model factory parameter names when previous contract had same-typed params in different order#10491
JoshLove-msft merged 3 commits intomicrosoft:mainfrom
JoshLove-msft:csharp/fix-param-preserve-non-paging

Conversation

@JoshLove-msft
Copy link
Copy Markdown
Contributor

Problem

PR #10464 introduced ModelFactoryProvider.PreservePreviousParameterNames, which positionally renames current factory-method parameters to match the previous contract whenever the method signatures match by name + parameter types. When the previous and current contracts both have two same-typed parameters but in opposite order, this positional rename silently swaps which parameter feeds which constructor field via the name-based lookup in GetCtorArgs.

This is a real correctness regression and a source-breaking change. Concretely, Azure.AI.VoiceLive.VoiceLiveModelFactory.SessionUpdateConversationItemDeleted regenerates as:

public static SessionUpdateConversationItemDeleted SessionUpdateConversationItemDeleted(string itemId0 = default, string eventId0 = default)
{
    return new SessionUpdateConversationItemDeleted(ServerEventType.ConversationItemDeleted, itemId0, additionalBinaryDataProperties: null, eventId0);
}

Note itemId0 is passed to the constructor's eventId field and eventId0 to the itemId field — values silently end up in the wrong properties. Reproduced via the auto-generated Azure SDK PR Azure/azure-sdk-for-net#58632.

Fix

In PreservePreviousParameterNames, skip the rename when applying it would cause previousName to collide with another current parameter's current name. This preserves the legitimate casing/spec-rename use case (e.g. metricname -> metricName) while preventing the swap pathology.

After the fix, the same factory regenerates correctly:

public static SessionUpdateConversationItemDeleted SessionUpdateConversationItemDeleted(string eventId = default, string itemId = default)
{
    return new SessionUpdateConversationItemDeleted(ServerEventType.ConversationItemDeleted, eventId, additionalBinaryDataProperties: null, itemId);
}

Tests

Added BackCompatibility_SwapTypeParamsDoesNotCorrupt regression test covering the case where the previous contract has two same-typed parameters in swapped order. All 1434 Generator + 1312 ClientModel tests pass.

--generated by Copilot

JoshLove-msft and others added 3 commits April 24, 2026 11:02
…compat baseline when no rename occurred

The generic back-compat preservation introduced for non-paging parameters
unconditionally rewrote any current parameter to the casing/spelling found
in the previous published contract. When the spec parameter name had not
been renamed by the generator (Name == OriginalName), this caused
parameters whose casing had been intentionally changed in a more recent
release to be silently rewritten back to a stale baseline's casing,
producing a source-breaking rename for callers using named arguments.

Restrict the generic preservation to actual generator-introduced renames
(Name != OriginalName). The paging-specific renames (top -> maxCount,
maxpagesize -> maxPageSize) continue to be preserved via their own
explicit calls.

Reproduced against Azure.Developer.LoadTesting where `metricname` was
being silently rewritten to `metricName` because the API-compat baseline
(an older 1.0.2 release) still had the capitalized form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…om back-compat baseline when no rename occurred"

This reverts commit fd196b9.
… when previous contract had same-typed params in a different order

PreservePreviousParameterNames in ModelFactoryProvider does positional renames
based on the matching previous method's parameters. When the previous and
current contracts both have two same-typed parameters but in OPPOSITE order,
the positional rename swapped the parameter NAMES while the body still
referenced the original ParameterProvider object identities. The result was
that GetCtorArgs (which matches by name) wired each parameter to the wrong
constructor field — a real correctness regression and source-breaking change
for users of model factory methods.

Reproduced via Azure.AI.VoiceLive.SessionUpdateConversationItemDeleted, which
emitted `new SessionUpdateConversationItemDeleted(.., itemId0, .., eventId0)`
where the values intended for `itemId` ended up assigned to `eventId` and
vice versa.

Fix: skip the rename when applying it would cause `previousName` to collide
with another current parameter's current name. This preserves the legitimate
casing/spec-rename use case (e.g. metricname -> metricName) while preventing
the swap pathology.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp label Apr 24, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 24, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http-client-csharp@10491

commit: cfbbb0a

@github-actions
Copy link
Copy Markdown
Contributor

No changes needing a change description found.

@JoshLove-msft
Copy link
Copy Markdown
Contributor Author

Good question — I considered named arguments but they wouldn't fix this particular bug.

The root issue is that PreservePreviousParameterNames mutates ParameterProvider.Name in place via Update(name: ...) AFTER the method bodies have already been constructed. The mutation propagates to the cached _asVariable/_asArgument expressions that the body holds references to, so any code in the body that references a renamed parameter renders with the new name.

So even if GetCtorArgs produced named ctor arguments, e.g.:

new SessionUpdateConversationItemDeleted(@type, eventId: <P_evt.var>, additionalBinaryDataProperties: null, itemId: <P_itm.var>)

after the swap-rename runs, P_evt.var would be renamed to itemId and P_itm.var to eventId, and the output becomes:

new SessionUpdateConversationItemDeleted(@type, eventId: itemId, additionalBinaryDataProperties: null, itemId: eventId)

— same semantic swap (eventId field gets itemId).

To make named arguments help we'd have to either (a) rebuild the body after the rename, or (b) snapshot the original parameter names at body-build time so the body's references are decoupled from the ParameterProvider identities. Both are bigger changes than the collision guard in this PR. Happy to follow up with the broader refactor in a separate PR if you'd prefer a more structural fix, but the current change is a low-risk targeted guard.

--generated by Copilot

@JoshLove-msft JoshLove-msft enabled auto-merge April 24, 2026 19:31
@JoshLove-msft JoshLove-msft added this pull request to the merge queue Apr 24, 2026
Merged via the queue into microsoft:main with commit 7a75afa Apr 24, 2026
29 checks passed
@JoshLove-msft JoshLove-msft deleted the csharp/fix-param-preserve-non-paging branch April 24, 2026 20:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants