diff --git a/.chronus/changes/http-server-csharp-fix-duplicate-name-suffix-2026-7-30.md b/.chronus/changes/http-server-csharp-fix-duplicate-name-suffix-2026-7-30.md new file mode 100644 index 00000000000..de2b3fd6abd --- /dev/null +++ b/.chronus/changes/http-server-csharp-fix-duplicate-name-suffix-2026-7-30.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-server-csharp" +--- + +Fix generated class/interface names incorrectly receiving a `_2` suffix (e.g. `OperationsController_2`, `IOperations_2`, `ErrorResponse_2`) when a non-service library namespace (such as `Azure.ResourceManager`) defines types with the same name as types in the `@service` namespace. The emitter now restricts its collection of interfaces and models to `@service`-decorated namespaces, preventing the naming collision. diff --git a/packages/http-server-csharp/src/service-discovery.ts b/packages/http-server-csharp/src/service-discovery.ts index c72b918308c..4cff4e11f94 100644 --- a/packages/http-server-csharp/src/service-discovery.ts +++ b/packages/http-server-csharp/src/service-discovery.ts @@ -2,6 +2,7 @@ import { Interface, isStdNamespace, isTemplateDeclaration, + listServices, type Operation, type Namespace as TspNamespace, } from "@typespec/compiler"; @@ -12,12 +13,16 @@ import { getCSharpIdentifier, NameCasingType } from "./utils/naming.js"; * Collects all interfaces from the service namespace(s). * Also creates synthetic interfaces for namespace-level operations * (following the old emitter pattern: `${namespaceName}Operations`). + * + * Only collects from `@service`-decorated namespaces to avoid including interfaces + * from library namespaces (e.g. Azure.ResourceManager) that share the same interface + * names, which would cause duplicate class names with a `_2` suffix in the generated C#. + * Falls back to scanning all non-std namespaces when no `@service` is present (e.g. tests). */ export function getServiceInterfaces( program: ReturnType["$"]["program"], ): Interface[] { const interfaces: Interface[] = []; - const globalNs = program.getGlobalNamespaceType(); function collectFromNamespace(ns: TspNamespace): void { // Collect explicit TypeSpec interfaces @@ -61,9 +66,20 @@ export function getServiceInterfaces( } } - for (const ns of globalNs.namespaces.values()) { - if (isStdNamespace(ns)) continue; - collectFromNamespace(ns); + const services = listServices(program); + if (services.length > 0) { + // Restrict to @service-decorated namespaces to avoid picking up interfaces from + // library namespaces (e.g. Azure.ResourceManager) that would cause duplicate names. + for (const service of services) { + collectFromNamespace(service.type); + } + } else { + // Fallback: no @service decorator – scan all non-std top-level namespaces as before. + const globalNs = program.getGlobalNamespaceType(); + for (const ns of globalNs.namespaces.values()) { + if (isStdNamespace(ns)) continue; + collectFromNamespace(ns); + } } return interfaces; } diff --git a/packages/http-server-csharp/src/service-resolution.ts b/packages/http-server-csharp/src/service-resolution.ts index 281c6147799..39bda2e1509 100644 --- a/packages/http-server-csharp/src/service-resolution.ts +++ b/packages/http-server-csharp/src/service-resolution.ts @@ -2,6 +2,7 @@ import { getNamespaceFullName, isStdNamespace, isTemplateDeclaration, + listServices, type Enum, type Interface, type Model, @@ -83,7 +84,7 @@ export function resolveServiceTypes( // not payload data, so they must not be emitted as C# model classes (aligns with // the OpenAPI3 emitter, which emits them under `components.securitySchemes`). const authModels = getAuthSchemeModels(program); - const models = getServiceModels($, globalNs, authModels); + const models = getServiceModels($, program, authModels); // Phase 6: Canonicalize all HTTP operations const canonicalOpsMap = canonicalizeAllInterfaces(canonicalizer, interfaces); @@ -160,10 +161,20 @@ function canonicalizeAllInterfaces( * Retrieves all models from the program that should be emitted. * Includes namespace-level models AND models referenced by operations. * + * Only seeds the initial collection from `@service`-decorated namespaces to avoid + * picking up models from library namespaces (e.g. Azure.ResourceManager.CommonTypes) + * that share names with service models and would cause duplicate class names with a + * `_2` suffix. Models that are transitively referenced by service operations are still + * discovered and emitted via discoverReferencedModels / discoverModelsInType regardless + * of which namespace they live in. + * + * Falls back to scanning all non-std namespaces when no `@service` is present. + * * @param authModels Models that back authentication schemes; these are excluded * from emission because they represent protocol metadata rather than payloads. */ -function getServiceModels($: Typekit, globalNs: TspNamespace, authModels: Set): Model[] { +function getServiceModels($: Typekit, program: Program, authModels: Set): Model[] { + const globalNs = program.getGlobalNamespaceType(); const models: Model[] = []; const seen = new Set(); @@ -176,30 +187,55 @@ function getServiceModels($: Typekit, globalNs: TspNamespace, authModels: Set 0) { + // Restrict initial collection to @service-decorated namespaces to avoid picking up + // models from library namespaces (e.g. Azure.ResourceManager) that would otherwise + // cause duplicate class name collisions (the infamous `_2` suffix bug). + for (const service of services) { + collectModelsFromNamespace($, service.type, models, seen, authModels); + } - // Walk operations to discover referenced models (template instantiations, etc.) - const visited = new Set(); - for (const ns of globalNs.namespaces.values()) { - if (isStdNamespace(ns)) continue; - discoverReferencedModels($, ns, addModel, visited); - } + // Walk operations in service namespaces to discover referenced models. + const visited = new Set(); + for (const service of services) { + discoverReferencedModels($, service.type, addModel, visited); + } - // Walk all collected model properties to discover anonymous sub-models - const modelsSnapshot = [...models]; - for (const model of modelsSnapshot) { - for (const prop of model.properties.values()) { - discoverModelsInType($, prop.type, addModel, visited); + // Walk all collected model properties to discover anonymous sub-models. + const modelsSnapshot = [...models]; + for (const model of modelsSnapshot) { + for (const prop of model.properties.values()) { + discoverModelsInType($, prop.type, addModel, visited); + } + if (model.baseModel) { + discoverModelsInType($, model.baseModel, addModel, visited); + } + } + } else { + // Fallback: no @service decorator – behave as before and scan all non-std namespaces. + for (const model of globalNs.models.values()) { + addModel(model); + } + for (const ns of globalNs.namespaces.values()) { + if (isStdNamespace(ns)) continue; + collectModelsFromNamespace($, ns, models, seen, authModels); + } + + const visited = new Set(); + for (const ns of globalNs.namespaces.values()) { + if (isStdNamespace(ns)) continue; + discoverReferencedModels($, ns, addModel, visited); } - if (model.baseModel) { - discoverModelsInType($, model.baseModel, addModel, visited); + + const modelsSnapshot = [...models]; + for (const model of modelsSnapshot) { + for (const prop of model.properties.values()) { + discoverModelsInType($, prop.type, addModel, visited); + } + if (model.baseModel) { + discoverModelsInType($, model.baseModel, addModel, visited); + } } } diff --git a/packages/http-server-csharp/test/service-discovery.test.ts b/packages/http-server-csharp/test/service-discovery.test.ts new file mode 100644 index 00000000000..4e55ac99fbe --- /dev/null +++ b/packages/http-server-csharp/test/service-discovery.test.ts @@ -0,0 +1,124 @@ +// Regression tests for service-discovery issues. +// These tests verify that the emitter correctly restricts collection to @service-decorated +// namespaces and does not pick up types from library/helper namespaces. + +import { TesterInstance, TestFileSystem } from "@typespec/compiler/testing"; +import assert from "assert"; +import { beforeEach, it } from "vitest"; +import { ApiTester, compileAndDiagnose } from "./test-host.js"; + +function getOutputFiles(fs: TestFileSystem): Map { + return fs.fs; +} + +let tester: TesterInstance; + +beforeEach(async () => { + tester = await ApiTester.createInstance(); +}); + +// Regression test for https://github.com/microsoft/typespec/issues/11493 +// When a library namespace (e.g. Azure.ResourceManager) defines an interface with the +// same name as one in the user's @service namespace (e.g. "Operations"), the emitter +// must NOT collect the library interface. Previously both were collected and the second +// got a `_2` suffix, causing a class/constructor name mismatch in the generated C#. +it("does not emit _2 suffix when a non-service namespace has an interface with the same name", async () => { + const spec = ` + // Simulate a library namespace (e.g. Azure.ResourceManager) that has its own + // Operations interface – the emitter must ignore it. + namespace LibraryNs { + interface Operations { + @get @route("/lib-ops") listLibOps(): void; + } + } + + @service(#{ title: "MyService" }) + namespace MyService { + interface Operations { + @get @route("/service-ops") listServiceOps(): void; + } + } + `; + + const [result] = await compileAndDiagnose(tester, spec, { "skip-format": true }); + + // The service interface should be generated without any _2 suffix. + const files = getOutputFiles(result.fs); + const controllerFile = [...files.entries()].find(([k]) => k.includes("OperationsController.cs")); + const interfaceFile = [...files.entries()].find(([k]) => k.includes("IOperations.cs")); + + assert.ok(controllerFile, "OperationsController.cs should be emitted"); + assert.ok(interfaceFile, "IOperations.cs should be emitted"); + + const [, controllerContents] = controllerFile!; + const [, interfaceContents] = interfaceFile!; + + assert.ok( + !controllerContents.includes("_2"), + `OperationsController.cs must not contain '_2', but got:\n${controllerContents}`, + ); + assert.ok( + !interfaceContents.includes("_2"), + `IOperations.cs must not contain '_2', but got:\n${interfaceContents}`, + ); + + // Sanity-check that the correct class/interface names are present. + assert.ok( + controllerContents.includes("public partial class OperationsController"), + "Controller class should be named OperationsController", + ); + assert.ok( + interfaceContents.includes("public interface IOperations"), + "Business-logic interface should be named IOperations", + ); + + // The library namespace interface should NOT produce a controller file. + const libraryControllerFile = [...files.entries()].find(([k]) => + k.includes("LibraryNsOperationsController.cs"), + ); + assert.ok(!libraryControllerFile, "LibraryNs controller should not be emitted"); +}); + +// Regression test for https://github.com/microsoft/typespec/issues/11493 (model variant) +// When a library namespace defines a model with the same name as a service model, the +// emitter must not emit both, which would produce a `_2` suffix on the second class. +it("does not emit _2 suffix when a non-service namespace has a model with the same name", async () => { + const spec = ` + // Library namespace model with the same name as the service model. + namespace LibraryNs { + model ErrorResponse { + code: string; + } + } + + @service(#{ title: "MyService" }) + namespace MyService { + @error + model ErrorResponse { + @statusCode code: 400; + message: string; + } + + interface Ops { + @get @route("/items") list(): void; + } + } + `; + + const [result] = await compileAndDiagnose(tester, spec, { "skip-format": true }); + + const files = getOutputFiles(result.fs); + const modelFile = [...files.entries()].find(([k]) => k.includes("ErrorResponse.cs")); + + assert.ok(modelFile, "ErrorResponse.cs should be emitted"); + + const [, modelContents] = modelFile!; + assert.ok( + !modelContents.includes("_2"), + `ErrorResponse.cs must not contain '_2', but got:\n${modelContents}`, + ); + assert.ok( + modelContents.includes("public partial class ErrorResponse"), + "Model class should be named ErrorResponse", + ); +});