diff --git a/.chronus/changes/tcgc-duplicateModelNames-2026-0-14-16-17-15.md b/.chronus/changes/tcgc-duplicateModelNames-2026-0-14-16-17-15.md new file mode 100644 index 0000000000..4e961b092f --- /dev/null +++ b/.chronus/changes/tcgc-duplicateModelNames-2026-0-14-16-17-15.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@azure-tools/typespec-client-generator-core" +--- + +Correctly detect name collisions with `--namespace` flag and or multi service usage \ No newline at end of file diff --git a/packages/typespec-client-generator-core/src/context.ts b/packages/typespec-client-generator-core/src/context.ts index 5b828610be..40491b8bd7 100644 --- a/packages/typespec-client-generator-core/src/context.ts +++ b/packages/typespec-client-generator-core/src/context.ts @@ -47,6 +47,7 @@ import { TspLiteralType, } from "./internal-utils.js"; import { createSdkPackage } from "./package.js"; +import { validateNamespaceCollisions } from "./validations/types.js"; interface CreateTCGCContextOptions { mutateNamespace?: boolean; // whether to mutate global namespace for versioning @@ -217,6 +218,10 @@ export async function createSdkContext< } sdkContext.diagnostics = sdkContext.diagnostics.concat(diagnostics.diagnostics); + // Validate cross-namespace collisions (multi-service and Azure library conflicts) + // Done here to have access to sdkPackage and emitter options + validateNamespaceCollisions(sdkContext); + if (options?.exportTCGCoutput) { await exportTCGCOutput(sdkContext); } diff --git a/packages/typespec-client-generator-core/src/internal-utils.ts b/packages/typespec-client-generator-core/src/internal-utils.ts index b6bd1461ae..616d760bee 100644 --- a/packages/typespec-client-generator-core/src/internal-utils.ts +++ b/packages/typespec-client-generator-core/src/internal-utils.ts @@ -1122,3 +1122,91 @@ export function isSameAuth(left: Authentication, right: Authentication): boolean } return true; } + +/** + * Validates that there are no duplicate type names across namespaces when the namespace flag + * is used (which flattens all namespaces to a single namespace). + * + * This should be called during package creation when the namespaceFlag is set. + * + * @param context The TCGC context + * @param diagnostics The diagnostic collector to add errors to + */ +export function validateCrossNamespaceNamesWithFlag( + context: TCGCContext, + diagnostics: ReturnType, +): void { + // Only relevant when namespaceFlag flattens namespaces + if (!context.namespaceFlag) return; + + // Track all type names globally: name -> types with that name + const allTypeNames = new Map(); + + // Collect all types across all USER-DEFINED namespaces only + const userNamespaces = listAllUserDefinedNamespaces(context); + for (const ns of userNamespaces) { + collectTypesFromNamespace(context.program, ns, allTypeNames); + } + + // Report duplicates + for (const [name, types] of allTypeNames) { + if (types.length > 1) { + for (const type of types) { + diagnostics.add( + createDiagnostic({ + code: "duplicate-client-name", + messageId: "nonDecorator", + format: { name, scope: "AllScopes" }, + target: type, + }), + ); + } + } + } +} + +/** + * Collect top-level types from a single namespace (non-recursive). + * Only collects user-defined types. + */ +function collectTypesFromNamespace( + program: Program, + namespace: Namespace, + allTypeNames: Map, +): void { + // Collect models + for (const model of namespace.models.values()) { + if (model.name && $(program).type.isUserDefined(model)) { + const existing = allTypeNames.get(model.name) ?? []; + existing.push(model); + allTypeNames.set(model.name, existing); + } + } + + // Collect enums + for (const enumType of namespace.enums.values()) { + if (enumType.name && $(program).type.isUserDefined(enumType)) { + const existing = allTypeNames.get(enumType.name) ?? []; + existing.push(enumType); + allTypeNames.set(enumType.name, existing); + } + } + + // Collect unions + for (const unionType of namespace.unions.values()) { + if (unionType.name && $(program).type.isUserDefined(unionType)) { + const existing = allTypeNames.get(unionType.name) ?? []; + existing.push(unionType); + allTypeNames.set(unionType.name, existing); + } + } + + // Collect interfaces + for (const iface of namespace.interfaces.values()) { + if (iface.name && $(program).type.isUserDefined(iface)) { + const existing = allTypeNames.get(iface.name) ?? []; + existing.push(iface); + allTypeNames.set(iface.name, existing); + } + } +} diff --git a/packages/typespec-client-generator-core/src/package.ts b/packages/typespec-client-generator-core/src/package.ts index c733aacc25..665384a364 100644 --- a/packages/typespec-client-generator-core/src/package.ts +++ b/packages/typespec-client-generator-core/src/package.ts @@ -23,6 +23,7 @@ import { filterApiVersionsWithDecorators, getActualClientType, getTypeDecorators, + validateCrossNamespaceNamesWithFlag, } from "./internal-utils.js"; import { getLicenseInfo } from "./license.js"; import { getCrossLanguagePackageId, getNamespaceFromType } from "./public-utils.js"; @@ -32,6 +33,11 @@ export function createSdkPackage( context: TCGCContext, ): [SdkPackage, readonly Diagnostic[]] { const diagnostics = createDiagnosticCollector(); + + // Validate cross-namespace names if namespace flag is set (flattens namespaces) + // Can't validate in $onValidate bc we don't have access to the namespace flag + validateCrossNamespaceNamesWithFlag(context, diagnostics); + populateApiVersionInformation(context); diagnostics.pipe(handleAllTypes(context)); const crossLanguagePackageId = diagnostics.pipe(getCrossLanguagePackageId(context)); diff --git a/packages/typespec-client-generator-core/src/validate.ts b/packages/typespec-client-generator-core/src/validate.ts index 038a7ee202..738148e738 100644 --- a/packages/typespec-client-generator-core/src/validate.ts +++ b/packages/typespec-client-generator-core/src/validate.ts @@ -15,5 +15,8 @@ export function $onValidate(program: Program) { validateClients(tcgcContext); validateMethods(tcgcContext); validateHttp(tcgcContext); + // Basic client name validation within namespaces validateTypes(tcgcContext); + // Note: Cross-namespace collision validation (multi-service and Azure library conflicts) + // is done in createSdkContext where we have access to sdkPackage and emitter options } diff --git a/packages/typespec-client-generator-core/src/validations/types.ts b/packages/typespec-client-generator-core/src/validations/types.ts index 017e9666ae..e44d0899c8 100644 --- a/packages/typespec-client-generator-core/src/validations/types.ts +++ b/packages/typespec-client-generator-core/src/validations/types.ts @@ -16,32 +16,56 @@ import { AugmentDecoratorStatementNode, DecoratorExpressionNode } from "@typespe import { unsafe_Realm } from "@typespec/compiler/experimental"; import { DuplicateTracker } from "@typespec/compiler/utils"; import { getClientNameOverride } from "../decorators.js"; -import { TCGCContext } from "../interfaces.js"; +import { SdkContext, TCGCContext, UsageFlags } from "../interfaces.js"; import { AllScopes, clientLocationKey, clientNameKey, hasExplicitClientOrOperationGroup, + listAllUserDefinedNamespaces, listScopedDecoratorData, } from "../internal-utils.js"; import { reportDiagnostic } from "../lib.js"; export function validateTypes(context: TCGCContext) { - validateClientNames(context); + validateClientNamesWithinNamespaces(context); } /** - * Validate naming with `@clientName` and `@clientLocation` decorators. - * - * This function checks for duplicate client names for types considering the impact of `@clientName` for all possible scopes. + * Validate cross-namespace collisions when the --namespace flag is set. + * This runs in createSdkContext where we have access to sdkPackage and emitter options. + */ +export function validateNamespaceCollisions(context: SdkContext) { + validateCrossNamespaceClientNames(context); +} + +/** + * Validate basic client name duplicates within namespaces. + * This checks for duplicate client names for types considering the impact of `@clientName` for all possible scopes. * It also handles the movement of operations to new clients based on the `@clientLocation` decorators. * - * @param tcgcContext The context for the TypeSpec Client Generator. + * This runs in $onValidate and doesn't require SdkContext. + * + * @param tcgcContext The TCGC context. */ -function validateClientNames(tcgcContext: TCGCContext) { +function validateClientNamesWithinNamespaces(tcgcContext: TCGCContext) { const languageScopes = getDefinedLanguageScopes(tcgcContext.program); // If no `@client` or `@operationGroup` decorators are defined, we consider `@clientLocation` const needToConsiderClientLocation = !hasExplicitClientOrOperationGroup(tcgcContext); + + // Ensure we always run validation at least once (with AllScopes) + if (languageScopes.size === 0) { + languageScopes.add(AllScopes); + } + + // Build a map of namespace names to their types for resolving string targets + const namedNamespaces = new Map(); + for (const ns of listAllUserDefinedNamespaces(tcgcContext)) { + if (ns.name) { + namedNamespaces.set(ns.name, ns); + } + } + // Check all possible language scopes for (const scope of languageScopes) { // Gather all moved operations and their targets @@ -62,14 +86,25 @@ function validateClientNames(tcgcContext: TCGCContext) { if (type.kind === "Operation") { moved.add(type); if (typeof target === "string") { - // Move to new clients - if (!newClients.has(target)) { - newClients.set(target, [type]); + // Check if the string target matches an existing namespace + const existingNamespace = namedNamespaces.get(target); + if (existingNamespace) { + // Move to existing namespace referenced by name + if (!movedTo.has(existingNamespace)) { + movedTo.set(existingNamespace, [type]); + } else { + movedTo.get(existingNamespace)!.push(type); + } } else { - newClients.get(target)!.push(type); + // Move to new clients (string doesn't match any existing namespace) + if (!newClients.has(target)) { + newClients.set(target, [type]); + } else { + newClients.get(target)!.push(type); + } } } else { - // Move to existing clients + // Move to existing clients (target is already a Namespace or Interface) if (!movedTo.has(target)) { movedTo.set(target, [type]); } else { @@ -80,7 +115,7 @@ function validateClientNames(tcgcContext: TCGCContext) { } } - // Validate client names for the current scope + // Per-namespace validation for client name duplicates validateClientNamesPerNamespace( tcgcContext, scope, @@ -96,6 +131,86 @@ function validateClientNames(tcgcContext: TCGCContext) { } } +/** + * Validate cross-namespace collisions when the --namespace flag is set. + * When namespaces are flattened, types from different namespaces may collide. + * Also checks for Azure library type conflicts. + * + * This requires SdkContext because: + * 1. Needs access to namespaceFlag emitter option + * 2. Azure library conflict detection needs sdkPackage to check only used types + * + * @param sdkContext The SDK context (includes sdkPackage and emitter options). + */ +function validateCrossNamespaceClientNames(sdkContext: SdkContext) { + // Check if any Azure library namespace exists (Azure.Core or Azure.ResourceManager) + const azureLibraryNamespaces = getAzureLibraryNamespaces(sdkContext.program); + const hasAzureLibrary = azureLibraryNamespaces.length > 0; + + // Only run if there's cross-namespace validation to do: + // - namespaceFlag is set (types will be flattened) + // - or Azure library is present (need to check for conflicts) + if (!sdkContext.namespaceFlag && !hasAzureLibrary) { + return; + } + + const languageScopes = getDefinedLanguageScopes(sdkContext.program); + + // Ensure we always run validation at least once (with AllScopes) + if (languageScopes.size === 0) { + languageScopes.add(AllScopes); + } + + // Pre-compute Azure library type names once (O(A) where A = types in Azure libraries) + // This avoids recomputing for each scope + let azureTypeNames: Set | undefined; + if (hasAzureLibrary) { + azureTypeNames = new Set(); + for (const ns of azureLibraryNamespaces) { + const names = collectTypeNamesFromNamespace(ns); + for (const name of names) { + azureTypeNames.add(name); + } + } + } + + // Pre-compute used types from sdkPackage once (O(U) where U = used types) + // These are the only types we need to check for Azure library conflicts + let usedTypes: Set | undefined; + if (hasAzureLibrary) { + usedTypes = new Set(); + for (const model of sdkContext.sdkPackage.models) { + if (model.__raw) { + usedTypes.add(model.__raw); + } + } + for (const enumType of sdkContext.sdkPackage.enums) { + if (enumType.__raw) { + usedTypes.add(enumType.__raw); + } + } + for (const unionType of sdkContext.sdkPackage.unions) { + if (unionType.__raw) { + usedTypes.add(unionType.__raw); + } + } + } + + // Check all possible language scopes + for (const scope of languageScopes) { + // When namespaceFlag is set, validate types across ALL namespaces together + // because they will be flattened into a single namespace + if (sdkContext.namespaceFlag) { + validateClientNamesAcrossAllNamespaces(sdkContext, scope); + } + + // Check for Azure library type conflicts (only for types actually used by the client) + if (azureTypeNames !== undefined && usedTypes !== undefined) { + validateAzureLibraryTypeConflicts(sdkContext, scope, azureTypeNames, usedTypes); + } + } +} + function getDefinedLanguageScopes(program: Program): Set { const languageScopes = new Set(); const impacted = [...program.stateMap(clientNameKey).values()]; @@ -138,12 +253,18 @@ function validateClientNamesPerNamespace( movedTo: Map, namespace: Namespace, ) { - // Check for duplicate client names for models, enums, and unions - validateClientNamesCore(tcgcContext, scope, [ + // Collect types from the namespace + const typesToValidate: (Model | Enum | Union)[] = [ ...namespace.models.values(), ...namespace.enums.values(), ...namespace.unions.values(), - ]); + ]; + + // Check for duplicate client names for models, enums, and unions + validateClientNamesCore(tcgcContext, scope, typesToValidate); + + // Check for duplicate client names for scalars + validateClientNamesCore(tcgcContext, scope, namespace.scalars.values()); // Check for duplicate client names for operations validateClientNamesCore( @@ -164,9 +285,6 @@ function validateClientNamesPerNamespace( // Check for duplicate client names for interfaces validateClientNamesCore(tcgcContext, scope, namespace.interfaces.values()); - // Check for duplicate client names for scalars - validateClientNamesCore(tcgcContext, scope, namespace.scalars.values()); - // Check for duplicate client names for namespaces validateClientNamesCore(tcgcContext, scope, namespace.namespaces.values()); @@ -191,6 +309,146 @@ function validateClientNamesPerNamespace( } } +/** + * Get Azure library namespaces (Azure.Core and Azure.ResourceManager) if they exist. + * These are used to detect naming conflicts between user-defined types + * and built-in Azure library types. + */ +function getAzureLibraryNamespaces(program: Program): Namespace[] { + const namespaces: Namespace[] = []; + const globalNs = program.getGlobalNamespaceType(); + const azureNs = globalNs.namespaces.get("Azure"); + if (!azureNs) return namespaces; + + const coreNs = azureNs.namespaces.get("Core"); + if (coreNs) { + namespaces.push(coreNs); + } + + const armNs = azureNs.namespaces.get("ResourceManager"); + if (armNs) { + namespaces.push(armNs); + } + + return namespaces; +} + +/** + * Collect all type names from a namespace recursively. + * This includes models, enums, and unions from all nested namespaces. + */ +function collectTypeNamesFromNamespace(namespace: Namespace): Set { + const names = new Set(); + + for (const model of namespace.models.values()) { + if (model.name) { + names.add(model.name); + } + } + for (const enumType of namespace.enums.values()) { + if (enumType.name) { + names.add(enumType.name); + } + } + for (const union of namespace.unions.values()) { + if (union.name) { + names.add(union.name); + } + } + + // Recursively collect from nested namespaces + for (const nestedNs of namespace.namespaces.values()) { + const nestedNames = collectTypeNamesFromNamespace(nestedNs); + for (const name of nestedNames) { + names.add(name); + } + } + + return names; +} + +/** + * Validate that user-defined types with @clientName don't conflict with Azure library types. + * This is a targeted check that only reports conflicts when: + * 1. A user type has an explicit @clientName decorator + * 2. The client name matches an Azure library type name (from Azure.Core or Azure.ResourceManager) + * 3. The type is actually used by the client (present in sdkPackage) + * + * This avoids false positives from Azure's own internal type duplicates and unused types. + * + * Time complexity: O(U) where U = number of used types in sdkPackage + * Space complexity: O(1) - only uses pre-computed sets passed in, no new allocations + */ +function validateAzureLibraryTypeConflicts( + sdkContext: SdkContext, + scope: string | typeof AllScopes, + azureTypeNames: Set, + usedTypes: Set, +) { + // Check only used types for conflicts + for (const userType of usedTypes) { + if (userType.kind !== "Model" && userType.kind !== "Enum" && userType.kind !== "Union") { + continue; + } + // Only check types that have an explicit @clientName decorator + const clientName = getClientNameOverride(sdkContext, userType, scope); + if (clientName !== undefined && azureTypeNames.has(clientName)) { + // User type with @clientName conflicts with an Azure library type + const clientNameDecorator = userType.decorators.find( + (x) => x.definition?.name === "@clientName", + ); + if (clientNameDecorator?.node !== undefined) { + const scopeStr = scope === AllScopes ? "AllScopes" : scope; + reportDiagnostic(sdkContext.program, { + code: "duplicate-client-name", + format: { name: clientName, scope: scopeStr }, + target: clientNameDecorator.node, + }); + } + } + } +} + +/** + * Validate client names across all user-defined namespaces when the --namespace flag is set. + * When namespaces are flattened, types from different namespaces may collide. + */ +function validateClientNamesAcrossAllNamespaces( + sdkContext: SdkContext, + scope: string | typeof AllScopes, +) { + // Collect all types from all user-defined namespaces + // Note: listAllUserDefinedNamespaces already includes nested namespaces, + // so we collect types directly from each namespace without recursion + const allModels: Model[] = []; + const allEnums: Enum[] = []; + const allUnions: Union[] = []; + + for (const ns of listAllUserDefinedNamespaces(sdkContext)) { + // Collect types directly from this namespace (not recursively) + allModels.push(...ns.models.values()); + allEnums.push(...ns.enums.values()); + allUnions.push(...ns.unions.values()); + } + + // Pre-compute API version enum names to exclude from duplicate name validation + // API version enums (e.g., "Versions") commonly have the same name across services and that's OK + // We store names instead of Type references because versioning can create different type instances + const apiVersionEnumNames = new Set(); + for (const enumType of sdkContext.sdkPackage.enums) { + if ((enumType.usage & UsageFlags.ApiVersionEnum) !== 0) { + apiVersionEnumNames.add(enumType.name); + } + } + + // Filter out API version enums - they commonly have the same name (e.g., "Versions") + // across services and that's expected/allowed + const filteredEnums = allEnums.filter((e) => !e.name || !apiVersionEnumNames.has(e.name)); + + // Validate models, enums, and unions together across all namespaces + validateClientNamesCore(sdkContext, scope, [...allModels, ...filteredEnums, ...allUnions]); +} + function validateClientNamesCore( tcgcContext: TCGCContext, scope: string | typeof AllScopes, diff --git a/packages/typespec-client-generator-core/test/clients/structure.test.ts b/packages/typespec-client-generator-core/test/clients/structure.test.ts index f69d3231cf..5733e8dda3 100644 --- a/packages/typespec-client-generator-core/test/clients/structure.test.ts +++ b/packages/typespec-client-generator-core/test/clients/structure.test.ts @@ -1560,6 +1560,9 @@ it("one client from multiple services with models shared across services", async ) @useDependency(ServiceA.VersionsA.av2, ServiceB.VersionsB.bv2) namespace CombineClient; + + // Rename ServiceB's SharedModel to avoid naming conflict + @@clientName(ServiceB.SharedModel, "SharedModelB"); `, ), ); @@ -1571,37 +1574,17 @@ it("one client from multiple services with models shared across services", async const client = sdkPackage.clients[0]; strictEqual(client.name, "CombineClient"); - // Both SharedModel types should exist - one from each service + // Both models should exist with different names const models = sdkPackage.models; strictEqual(models.length, 2); - const sharedModelA = models.find((m) => m.namespace === "ServiceA"); + const sharedModelA = models.find((m) => m.name === "SharedModel"); ok(sharedModelA); - strictEqual(sharedModelA.name, "SharedModel"); - strictEqual(sharedModelA.properties.length, 2); - const nameProperty = sharedModelA.properties.find((p) => p.name === "name"); - ok(nameProperty); - const descriptionProperty = sharedModelA.properties.find((p) => p.name === "description"); - ok(descriptionProperty); - - const sharedModelB = models.find((m) => m.namespace === "ServiceB"); - ok(sharedModelB); - strictEqual(sharedModelB.name, "SharedModel"); - strictEqual(sharedModelB.properties.length, 2); - const idProperty = sharedModelB.properties.find((p) => p.name === "id"); - ok(idProperty); - const valueProperty = sharedModelB.properties.find((p) => p.name === "value"); - ok(valueProperty); - - const aiClient = client.children!.find((c) => c.name === "AI"); - ok(aiClient); - strictEqual(aiClient.apiVersions.length, 2); - deepStrictEqual(aiClient.apiVersions, ["av1", "av2"]); + strictEqual(sharedModelA.namespace, "ServiceA"); - const biClient = client.children!.find((c) => c.name === "BI"); - ok(biClient); - strictEqual(biClient.apiVersions.length, 2); - deepStrictEqual(biClient.apiVersions, ["bv1", "bv2"]); + const sharedModelB = models.find((m) => m.name === "SharedModelB"); + ok(sharedModelB); + strictEqual(sharedModelB.namespace, "ServiceB"); }); it("error: multiple explicit clients with multiple services", async () => { diff --git a/packages/typespec-client-generator-core/test/validations/types.test.ts b/packages/typespec-client-generator-core/test/validations/types.test.ts index 42748901d6..cc206dc426 100644 --- a/packages/typespec-client-generator-core/test/validations/types.test.ts +++ b/packages/typespec-client-generator-core/test/validations/types.test.ts @@ -1,6 +1,518 @@ import { expectDiagnosticEmpty, expectDiagnostics } from "@typespec/compiler/testing"; -import { it } from "vitest"; -import { createSdkContextForTester, SimpleTester } from "../tester.js"; +import { describe, it } from "vitest"; +import { + ArmTester, + createClientCustomizationInput, + createSdkContextForTester, + SimpleBaseTester, + SimpleTester, +} from "../tester.js"; + +describe("cross-namespace duplicate name validation", () => { + // Cross-namespace validation runs when the --namespace flag is set. + // When namespaces are flattened, types with the same name across different namespaces + // will collide in the generated client. + + it("error for same model name across namespaces with namespace flag", async () => { + // Same-named models in different namespaces will collide when namespace flag is set + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + model Foo { a: string; } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + model Foo { b: string; } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + const context = await createSdkContextForTester(program, { namespace: "CombineClient" }); + // Filter to get only the cross-namespace duplicate diagnostics from program + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnostics(duplicateDiagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Foo" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Foo" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + ]); + }); + + it("error for same enum name across namespaces with namespace flag", async () => { + // Same-named enums in different namespaces will collide when namespace flag is set + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + enum Status { Active, Inactive } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + enum Status { Pending, Complete } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + const context = await createSdkContextForTester(program, { namespace: "CombineClient" }); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnostics(duplicateDiagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Status" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Status" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + ]); + }); + + it("error for same union name across namespaces with namespace flag", async () => { + // Same-named unions in different namespaces will collide when namespace flag is set + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + union MyUnion { string, int32 } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + union MyUnion { boolean, float32 } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + const context = await createSdkContextForTester(program, { namespace: "CombineClient" }); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnostics(duplicateDiagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "MyUnion" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "MyUnion" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + ]); + }); + + it("no error for different names across namespaces with namespace flag", async () => { + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + model FooA { a: string; } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + model FooB { b: string; } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + const context = await createSdkContextForTester(program, { namespace: "CombineClient" }); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnosticEmpty(duplicateDiagnostics); + }); + + it("error for @clientName same name across namespaces with namespace flag", async () => { + // @clientName causing same name across namespaces will collide when namespace flag is set + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + @clientName("SharedName") + model ModelA { a: string; } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + @clientName("SharedName") + model ModelB { b: string; } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + const context = await createSdkContextForTester(program, { namespace: "CombineClient" }); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnostics(duplicateDiagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: 'Client name: "SharedName" is duplicated in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: 'Client name: "SharedName" is duplicated in language scope: "AllScopes"', + }, + ]); + }); + + it("error for nested namespace type with same name with namespace flag", async () => { + // Nested namespaces will also collide when namespace flag is set + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + namespace Sub { + model Nested { a: string; } + } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + namespace Sub { + model Nested { b: string; } + } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + const context = await createSdkContextForTester(program, { namespace: "CombineClient" }); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnostics(duplicateDiagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Nested" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Nested" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + ]); + }); + + it("no error for same model name in single-service (different namespaces)", async () => { + // In single-service mode, same names in different namespaces are OK + const diagnostics = await SimpleTester.diagnose( + ` + @service + namespace MyService { + namespace SubA { + model Foo { a: string; } + } + namespace SubB { + model Foo { b: string; } + } + } + `, + ); + + expectDiagnosticEmpty(diagnostics); + }); + + it("no error for same API version enum name across namespaces with namespace flag", async () => { + // API version enums (e.g., "Versions") commonly have the same name across services + // and that's expected and allowed - they're identified by UsageFlags.ApiVersionEnum + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(Versions) + namespace ServiceA { + enum Versions { v1 } + model FooA { a: string; } + @route("/a") op getA(): FooA; + } + @service + @versioned(Versions) + namespace ServiceB { + enum Versions { v1 } + model FooB { b: string; } + @route("/b") op getB(): FooB; + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.Versions.v1, ServiceB.Versions.v1) + namespace CombineClient; + `, + ), + ); + + const context = await createSdkContextForTester(program, { namespace: "CombineClient" }); + // Should not report errors for "Versions" enums being duplicated + // because they are API version enums + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnosticEmpty(duplicateDiagnostics); + }); + + it("no error for same model name across services in multi-service client without namespace flag", async () => { + // Multi-service clients without namespace flag should NOT raise duplicate name errors + // because the namespaces are not being flattened + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + model Foo { a: string; } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + model Foo { b: string; } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + // No namespace flag - should not report cross-namespace duplicates + const context = await createSdkContextForTester(program); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnosticEmpty(duplicateDiagnostics); + }); + + it("no error for same enum name across services in multi-service client without namespace flag", async () => { + // Multi-service clients without namespace flag should NOT raise duplicate name errors + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(VersionsA) + namespace ServiceA { + enum VersionsA { v1 } + enum Status { Active, Inactive } + } + @service + @versioned(VersionsB) + namespace ServiceB { + enum VersionsB { v1 } + enum Status { Pending, Complete } + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.VersionsA.v1, ServiceB.VersionsB.v1) + namespace CombineClient; + `, + ), + ); + + // No namespace flag - should not report cross-namespace duplicates + const context = await createSdkContextForTester(program); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnosticEmpty(duplicateDiagnostics); + }); + + it("no error for same API version enum name across services in multi-service client without namespace flag", async () => { + // API version enums with the same name in multi-service clients without namespace flag should not error + const { program } = await SimpleBaseTester.compile( + createClientCustomizationInput( + ` + @service + @versioned(Versions) + namespace ServiceA { + enum Versions { v1 } + model FooA { a: string; } + @route("/a") op getA(): FooA; + } + @service + @versioned(Versions) + namespace ServiceB { + enum Versions { v1 } + model FooB { b: string; } + @route("/b") op getB(): FooB; + } + `, + ` + @client({ name: "CombineClient", service: [ServiceA, ServiceB] }) + @useDependency(ServiceA.Versions.v1, ServiceB.Versions.v1) + namespace CombineClient; + `, + ), + ); + + // No namespace flag - should not report any duplicates + const context = await createSdkContextForTester(program); + const duplicateDiagnostics = context.program.diagnostics.filter( + (d) => d.code === "@azure-tools/typespec-client-generator-core/duplicate-client-name", + ); + expectDiagnosticEmpty(duplicateDiagnostics); + }); + + it("error for duplicate model name within same service namespace", async () => { + // Within the same service namespace, duplicate names ARE an error + const diagnostics = await SimpleTester.diagnose( + ` + @service + namespace MyService { + model Foo { a: string; } + @clientName("Foo") + model Bar { b: string; } + } + `, + ); + + expectDiagnostics(diagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + }, + ]); + }); + + it("error for user-defined type conflicting with Azure.ResourceManager type", async () => { + // User-defined types should not conflict with ARM library types like ExtensionResource. + // Both the user's ExtensionResource and ARM's ExtensionResource would be generated + // into the SDK, causing naming conflicts. + const [{ program }, diagnostics] = await ArmTester.compileAndDiagnose(` + @armProviderNamespace("My.Service") + @server("http://localhost:3000", "endpoint") + @service(#{title: "My.Service"}) + @versioned(Versions) + @armCommonTypesVersion(CommonTypes.Versions.v5) + namespace My.Service; + + /** Api versions */ + enum Versions { + /** 2024-04-01-preview api version */ + V2024_04_01_PREVIEW: "2024-04-01-preview", + } + + // User defines a model with @clientName that conflicts with ARM's ExtensionResource + @clientName("ExtensionResource") + model MyExtensionResource { + name: string; + value: int32; + } + + model TestTrackedResource is TrackedResource { + ...ResourceNameParameter; + } + + model TestTrackedResourceProperties { + description?: string; + // Reference the user's ExtensionResource + extension?: MyExtensionResource; + } + + @armResourceOperations + interface Tests { + get is ArmResourceRead; + } + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-java", + }); + + // Should report a single diagnostic because user's @clientName("ExtensionResource") + // conflicts with ARM's ExtensionResource type + expectDiagnostics( + [...diagnostics, ...context.diagnostics], + [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: 'Client name: "ExtensionResource" is duplicated in language scope: "AllScopes"', + }, + ], + ); + }); +}); it("no duplicate operation with @clientLocation", async () => { const [{ program }, diagnostics] = await SimpleTester.compileAndDiagnose( @@ -84,12 +596,77 @@ it("duplicate operation with @clientLocation to existed clients", async () => { ]); }); +it("duplicate operation with @clientLocation string to existing namespace", async () => { + // When using a string that matches an existing namespace name, + // the operation should be moved to that namespace for validation + const diagnostics = await SimpleTester.diagnose( + ` + @service + namespace Contoso.WidgetManager; + + interface A { + @clientLocation("Test") + @route("/a") + op a(): void; + + @route("/b") + op b(): void; + } + + namespace Test { + @route("/c") + @clientName("a") + op c(): void; + } + `, + ); + + expectDiagnostics(diagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: 'Client name: "a" is duplicated in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "a" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + ]); +}); + +it("no duplicate operation with @clientLocation string to existing namespace", async () => { + // When using a string that matches an existing namespace name, + // the operation should be moved to that namespace - no conflict if names are different + const diagnostics = await SimpleTester.diagnose( + ` + @service + namespace Contoso.WidgetManager; + + interface A { + @clientLocation("Test") + @route("/a") + op a(): void; + + @route("/b") + op b(): void; + } + + namespace Test { + @route("/c") + op c(): void; + } + `, + ); + + expectDiagnosticEmpty(diagnostics); +}); + it("duplicate operation with @clientLocation to existed clients with scope", async () => { const [{ program }, diagnostics] = await SimpleTester.compileAndDiagnose( ` @service namespace Contoso.WidgetManager; - + interface A { @clientLocation(B, "go") @route("/a") @@ -190,7 +767,7 @@ it("duplicate operation error for other languages", async () => { ` @service namespace StorageService; - + interface StorageTasks { @route("/list") op list(): void; @@ -215,3 +792,88 @@ it("duplicate operation error for other languages", async () => { }, ]); }); + +describe("namespace flag duplicate name validation", () => { + it("cross-namespace collision with namespace flag", async () => { + const { program } = await SimpleTester.compile(` + @service + namespace MyService { + namespace SubA { + model Foo { a: string; } + } + namespace SubB { + model Foo { b: string; } + } + } + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-python", + namespace: "Flattened", + }); + + // When namespace flag is set, cross-namespace collisions should be reported + expectDiagnostics(context.diagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Foo" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Foo" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + ]); + }); + + it("no collision without namespace flag", async () => { + // Without namespace flag, same names in different namespaces are OK + const { program } = await SimpleTester.compile(` + @service + namespace MyService { + namespace SubA { + model Foo { a: string; } + } + namespace SubB { + model Foo { b: string; } + } + } + `); + + const context = await createSdkContextForTester(program); + expectDiagnosticEmpty(context.diagnostics); + }); + + it("cross-namespace enum collision with namespace flag", async () => { + const { program } = await SimpleTester.compile(` + @service + namespace MyService { + namespace SubA { + enum Status { Active } + } + namespace SubB { + enum Status { Pending } + } + } + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-python", + namespace: "Flattened", + }); + + expectDiagnostics(context.diagnostics, [ + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Status" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + { + code: "@azure-tools/typespec-client-generator-core/duplicate-client-name", + message: + 'Client name: "Status" is defined somewhere causing naming conflicts in language scope: "AllScopes"', + }, + ]); + }); +});