diff --git a/.chronus/changes/copilot-fix-missing-namespace-imports-mergepatcupdate-2026-03-04-01-11-36.md b/.chronus/changes/copilot-fix-missing-namespace-imports-mergepatcupdate-2026-03-04-01-11-36.md new file mode 100644 index 00000000000..f432bd604af --- /dev/null +++ b/.chronus/changes/copilot-fix-missing-namespace-imports-mergepatcupdate-2026-03-04-01-11-36.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Fix missing `using` namespace imports in C# files generated from `MergePatchUpdate` when model properties reference enum or named types from a different namespace diff --git a/packages/http-server-csharp/src/lib/interfaces.ts b/packages/http-server-csharp/src/lib/interfaces.ts index d7d80357cd1..ea7a5793626 100644 --- a/packages/http-server-csharp/src/lib/interfaces.ts +++ b/packages/http-server-csharp/src/lib/interfaces.ts @@ -84,18 +84,9 @@ export function checkOrAddNamespaceToScope( case "sourceFile": { const fileNameSpace = scope.sourceFile.meta["ResolvedNamespace"]; if (fileNameSpace && fileNameSpace.startsWith(ns)) return true; - for (const entry of scope.sourceFile.imports.keys()) { - if (entry === ns) { - return true; - } - } - const added: string | undefined = scope.sourceFile.meta["AddedScope"]; - if (added === undefined) { - scope.sourceFile.imports.set(ns, [ns]); - scope.sourceFile.meta["AddedScope"] = ns; - return true; - } - return false; + if (scope.sourceFile.imports.has(ns)) return true; + scope.sourceFile.imports.set(ns, [ns]); + return true; } default: return false; diff --git a/packages/http-server-csharp/src/lib/service.ts b/packages/http-server-csharp/src/lib/service.ts index 6f9b671f398..335925bf1d5 100644 --- a/packages/http-server-csharp/src/lib/service.ts +++ b/packages/http-server-csharp/src/lib/service.ts @@ -1172,6 +1172,7 @@ export async function $onEmit(context: EmitContext) #createEnumContext(namespace: string, file: SourceFile, name: string): Context { file.imports.set("System.Text.Json", ["System.Text.Json"]); file.imports.set("System.Text.Json.Serialization", ["System.Text.Json.Serialization"]); + file.meta[this.#nsKey] = namespace; return { namespace: namespace, diff --git a/packages/http-server-csharp/test/generation.test.ts b/packages/http-server-csharp/test/generation.test.ts index 7f5c0175c66..17c658e5035 100644 --- a/packages/http-server-csharp/test/generation.test.ts +++ b/packages/http-server-csharp/test/generation.test.ts @@ -1204,6 +1204,301 @@ interface Widgets { ); }); +it("Handles MergePatchUpdate with enum type in different namespace", async () => { + await compileAndValidateMultiple( + tester, + ` +enum WidgetColor { + Red, + Blue, + Green +} + +model Widget { + id: string; + weight: int32; + color: WidgetColor; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + /** Update a widget */ + @patch update(@path id: string, @body body: MergePatchUpdate): Widget; +} + `, + [ + [ + "WidgetMergePatchUpdate.cs", + [ + "namespace TypeSpec.Http {", + "using Microsoft.Contoso;", + "public string Id { get; set; }", + "public int? Weight { get; set; }", + "public WidgetColor? Color { get; set; }", + ], + ], + ], + ); +}); + +it("Handles MergePatchUpdate with properties from multiple different sub-namespaces", async () => { + // This test verifies that ALL cross-namespace using directives are emitted when + // multiple enum properties come from different namespaces (tests the removed AddedScope + // single-import limitation in checkOrAddNamespaceToScope). + await compileAndValidateMultiple( + tester, + ` +namespace Colors { + enum WidgetColor { Red, Blue, Green } +} + +namespace Sizes { + enum WidgetSize { Small, Medium, Large } +} + +model Widget { + id: string; + color: Colors.WidgetColor; + size: Sizes.WidgetSize; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + /** Update a widget */ + @patch update(@path id: string, @body body: MergePatchUpdate): Widget; +} + `, + [ + [ + "WidgetMergePatchUpdate.cs", + [ + "namespace TypeSpec.Http {", + "using Microsoft.Contoso.Colors;", + "using Microsoft.Contoso.Sizes;", + "public string Id { get; set; }", + "public WidgetColor? Color { get; set; }", + "public WidgetSize? Size { get; set; }", + ], + ], + ], + ); +}); + +it("Handles model with enum property from a sub-namespace", async () => { + // This test verifies that a regular (non-MergePatch) model whose property references + // an enum from a different namespace gets the correct using directive. + await compileAndValidateMultiple( + tester, + ` +namespace Colors { + enum WidgetColor { Red, Blue, Green } +} + +model Widget { + id: string; + color: Colors.WidgetColor; +} + +@get op getWidget(): Widget; + `, + [ + [ + "Widget.cs", + [ + "namespace Microsoft.Contoso {", + "using Microsoft.Contoso.Colors;", + "public string Id { get; set; }", + "public WidgetColor Color { get; set; }", + ], + ], + ], + ); +}); + +it("Handles MergePatchUpdate with optional enum from different namespace", async () => { + // Optional enums from different namespaces appear as nullable types (WidgetColor?) + // and must still get the correct using directive. + await compileAndValidateMultiple( + tester, + ` +enum WidgetColor { + Red, + Blue, + Green +} + +model Widget { + id: string; + color?: WidgetColor; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + /** Update a widget */ + @patch update(@path id: string, @body body: MergePatchUpdate): Widget; +} + `, + [ + [ + "WidgetMergePatchUpdate.cs", + [ + "namespace TypeSpec.Http {", + "using Microsoft.Contoso;", + "public string Id { get; set; }", + "public WidgetColor? Color { get; set; }", + ], + ], + ], + ); +}); + +it("Handles MergePatchUpdate with string-enum union property from different namespace", async () => { + // String-enum unions (e.g. union Color { "red", "blue" }) also use createEnumContext + // and should get the correct using directive when in a different namespace. + // Note: string-enum unions are MergePatch-transformed, so the property type becomes + // WidgetColorMergePatchUpdate (a new union in TypeSpec.Http), but the using directive + // for the original union's namespace is still needed for the union's definition file. + await compileAndValidateMultiple( + tester, + ` +namespace Colors { + union WidgetColor { Red: "red", Blue: "blue", Green: "green" } +} + +model Widget { + id: string; + color: Colors.WidgetColor; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + /** Update a widget */ + @patch update(@path id: string, @body body: MergePatchUpdate): Widget; +} + `, + [ + [ + "WidgetMergePatchUpdate.cs", + [ + "namespace TypeSpec.Http {", + "using Microsoft.Contoso.Colors;", + "public string Id { get; set; }", + ], + ], + ], + ); +}); + +it("Handles MergePatchUpdate with array of models from different namespace", async () => { + // Arrays of model types from different namespaces are also MergePatch-transformed, + // creating e.g. TagMergePatchUpdateReplaceOnly[] in TypeSpec.Http. + // The using directive for the original model's namespace should still be present. + await compileAndValidateMultiple( + tester, + ` +namespace Tags { + model Tag { name: string; value: string; } +} + +model Widget { + id: string; + tags: Tags.Tag[]; +} + +@route("/widgets") +@tag("Widgets") +interface Widgets { + /** Update a widget */ + @patch update(@path id: string, @body body: MergePatchUpdate): Widget; +} + `, + [ + [ + "WidgetMergePatchUpdate.cs", + [ + "namespace TypeSpec.Http {", + "using Microsoft.Contoso.Tags;", + "public string Id { get; set; }", + ], + ], + ], + ); +}); + +it("Emits using for base class namespace and separate property namespace (regression: AddedScope cap)", async () => { + // With the old AddedScope guard, checkOrAddNamespaceToScope returned false after adding + // the first dynamic namespace import, forcing subsequent ones to be fully-qualified. + // This test verifies that a model inheriting from a base class in one sub-namespace and + // having a property from a second sub-namespace gets BOTH using directives. + await compileAndValidateMultiple( + tester, + ` +namespace Models { + model ParentWidget { id: string; } +} + +namespace Colors { + enum WidgetColor { Red, Blue, Green } +} + +model Widget extends Models.ParentWidget { + color: Colors.WidgetColor; +} + +@get op getWidget(): Widget; + `, + [ + [ + "Widget.cs", + [ + "namespace Microsoft.Contoso {", + "using Microsoft.Contoso.Models;", + "using Microsoft.Contoso.Colors;", + "public WidgetColor Color { get; set; }", + ": ParentWidget", + ], + ], + ], + ); +}); + +it("Emits only one using directive when multiple properties share the same external namespace", async () => { + // Verifies that the import deduplication (imports.has(ns)) prevents duplicate + // using directives when more than one property references the same external namespace. + await compileAndValidateMultiple( + tester, + ` +namespace Colors { + enum WidgetColor { Red, Blue, Green } + enum BorderColor { Black, White } +} + +model Widget { + id: string; + color: Colors.WidgetColor; + borderColor: Colors.BorderColor; +} + +@get op getWidget(): Widget; + `, + [ + [ + "Widget.cs", + [ + "namespace Microsoft.Contoso {", + "using Microsoft.Contoso.Colors;", + "public WidgetColor Color { get; set; }", + "public BorderColor BorderColor { get; set; }", + ], + ], + ], + ); +}); + it("Handles user-defined model templates", async () => { await compileAndValidateMultiple( tester,