From 07b5a207c3b05e26c107f34931c1f408c7cd68b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:13:05 +0000 Subject: [PATCH 1/2] Initial plan From bf2a4729aeaa1ea96fe77554fe9916a2e672d911 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 02:30:18 +0000 Subject: [PATCH 2/2] Fix resolveArmResources incorrectly merging cross-scope LegacyOperations Add scope prefix comparison to isResourceOperationMatch when resource names are not explicitly provided. This prevents operations at different scopes (e.g., subscription vs tenant) from being incorrectly merged into a single resource when they share the same model. When a resource name is explicitly provided via the ResourceName parameter of LegacyOperations, matching continues to use the name for grouping, allowing intentional cross-scope merging. Co-authored-by: markcowl <1054056+markcowl@users.noreply.github.com> --- ...cy-operations-merge-2026-03-20-02-30-00.md | 7 + .../src/resource.ts | 56 +++- .../test/resource-resolution.test.ts | 314 +++++++++++++++++- 3 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 .chronus/changes/fix-cross-scope-legacy-operations-merge-2026-03-20-02-30-00.md diff --git a/.chronus/changes/fix-cross-scope-legacy-operations-merge-2026-03-20-02-30-00.md b/.chronus/changes/fix-cross-scope-legacy-operations-merge-2026-03-20-02-30-00.md new file mode 100644 index 0000000000..830aa76c23 --- /dev/null +++ b/.chronus/changes/fix-cross-scope-legacy-operations-merge-2026-03-20-02-30-00.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@azure-tools/typespec-azure-resource-manager" +--- + +Fix `resolveArmResources` incorrectly merging cross-scope `LegacyOperations` into a single resource. Operations at different scopes (e.g., subscription vs tenant) with the same model but no explicit resource name are now resolved as separate resources. diff --git a/packages/typespec-azure-resource-manager/src/resource.ts b/packages/typespec-azure-resource-manager/src/resource.ts index bf9541f8aa..6471de0890 100644 --- a/packages/typespec-azure-resource-manager/src/resource.ts +++ b/packages/typespec-azure-resource-manager/src/resource.ts @@ -157,6 +157,8 @@ export interface ResolvedResourceInfo { resourceInstancePath: string; /** The name of the resource at this instance path */ resourceName: string; + /** Whether the resource name was explicitly provided as a parameter */ + resourceNameIsExplicit?: boolean; } interface ResolvedResourceOperations { @@ -165,6 +167,8 @@ interface ResolvedResourceOperations { associatedOperations?: ArmResourceOperation[]; /** The name of the resource at this instance path */ resourceName: string; + /** Whether the resource name was explicitly provided as a parameter */ + resourceNameIsExplicit?: boolean; /** The resource type (The actual resource type string will be "${provider}/${types.join("/")}) */ resourceType: ResourceType; /** The path to the instance of a resource */ @@ -631,6 +635,30 @@ function isVariableSegment(segment: string): boolean { return (segment.startsWith("{") && segment.endsWith("}")) || segment === "default"; } +/** + * Extracts the scope prefix from a resource instance path. + * The scope prefix is the portion of the path before the last `/providers/` occurrence. + * For example: + * - `/subscriptions/{id}/providers/Microsoft.Foo/bars/{name}` → `/subscriptions/{id}` + * - `/providers/Microsoft.Foo/bars/{name}` → `` (empty) + */ +function getScopePrefix(resourceInstancePath: string): string { + const lastProviders = resourceInstancePath.lastIndexOf("/providers/"); + if (lastProviders <= 0) return ""; + return resourceInstancePath.slice(0, lastProviders); +} + +/** + * Normalizes a path for scope comparison by lowercasing static segments + * and replacing variable segments with a placeholder. + */ +function normalizePathForScopeComparison(path: string): string { + return path + .split("/") + .map((s) => (isVariableSegment(s) ? "{}" : s.toLowerCase())) + .join("/"); +} + function getResourceInfo( program: Program, operation: ArmResourceOperation, @@ -744,11 +772,13 @@ export function isResourceOperationMatch( resourceType: ResourceType; resourceInstancePath: string; resourceName?: string; + resourceNameIsExplicit?: boolean; }, target: { resourceType: ResourceType; resourceInstancePath: string; resourceName?: string; + resourceNameIsExplicit?: boolean; }, ): boolean { if ( @@ -764,17 +794,18 @@ export function isResourceOperationMatch( if (source.resourceType.types[i].toLowerCase() !== target.resourceType.types[i].toLowerCase()) return false; } - /*const sourceSegments = source.resourceInstancePath.split("/"); - const targetSegments = target.resourceInstancePath.split("/"); - if (sourceSegments.length !== targetSegments.length) return false; - for (let i = 0; i < sourceSegments.length; i++) { - if (!isVariableSegment(sourceSegments[i])) { - if (isVariableSegment(targetSegments[i])) { - return false; - } - if (sourceSegments[i].toLowerCase() !== targetSegments[i].toLowerCase()) return false; - } else if (!isVariableSegment(targetSegments[i])) return false; - }*/ + + // When neither resource has an explicitly provided resource name, also compare + // the scope prefix of the instance path to prevent merging cross-scope operations + if (!source.resourceNameIsExplicit && !target.resourceNameIsExplicit) { + const sourceScope = getScopePrefix(source.resourceInstancePath); + const targetScope = getScopePrefix(target.resourceInstancePath); + if ( + normalizePathForScopeComparison(sourceScope) !== normalizePathForScopeComparison(targetScope) + ) + return false; + } + return true; } @@ -874,10 +905,12 @@ export function resolveArmResourceOperations( if (resourceInfo === undefined) continue; armOperation.name = operation.name; armOperation.resourceKind = operation.resourceKind; + const resourceNameIsExplicit = operation.resourceName !== undefined; resourceInfo.resourceName = operation.resourceName ?? getResourceNameForOperation(program, armOperation, resourceInfo.resourceInstancePath) ?? armOperation.resourceModelName; + resourceInfo.resourceNameIsExplicit = resourceNameIsExplicit; armOperation.resourceName = resourceInfo.resourceName; let matched = false; @@ -899,6 +932,7 @@ export function resolveArmResourceOperations( resourceType: resourceInfo.resourceType, resourceInstancePath: resourceInfo.resourceInstancePath, resourceName: resourceInfo.resourceName, + resourceNameIsExplicit: resourceNameIsExplicit, operations: { lifecycle: { read: undefined, diff --git a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts index 6a1850d5ba..a72d720397 100644 --- a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts +++ b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts @@ -348,8 +348,16 @@ describe("unit tests for resource manager helpers", () => { describe("isResourceOperationMatch matches operations over the same resource", () => { const cases: { title: string; - source: { resourceType: ResourceType; resourceInstancePath: string }; - target: { resourceType: ResourceType; resourceInstancePath: string }; + source: { + resourceType: ResourceType; + resourceInstancePath: string; + resourceNameIsExplicit?: boolean; + }; + target: { + resourceType: ResourceType; + resourceInstancePath: string; + resourceNameIsExplicit?: boolean; + }; }[] = [ { title: "operations with default and parameterized names", @@ -408,6 +416,26 @@ describe("unit tests for resource manager helpers", () => { "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{providerName}/{resourceType}/{resourceName}/{childResourceType}/{childResourceName}/providers/Microsoft.Bar/bars/{barName}/basses/default", }, }, + { + title: "operations with different scopes but explicit resource names", + source: { + resourceType: { + provider: "Microsoft.Foo", + types: ["bars"], + }, + resourceInstancePath: + "/subscriptions/{subscriptionId}/providers/Microsoft.Foo/bars/{barName}", + resourceNameIsExplicit: true, + }, + target: { + resourceType: { + provider: "Microsoft.Foo", + types: ["bars"], + }, + resourceInstancePath: "/providers/Microsoft.Foo/bars/{barName}", + resourceNameIsExplicit: true, + }, + }, ]; for (const { title, source, target } of cases) { it(`matches ${title}`, () => { @@ -419,8 +447,18 @@ describe("unit tests for resource manager helpers", () => { describe("isResourceOperationMatch does not match operations over different resources", () => { const cases: { title: string; - source: { resourceType: ResourceType; resourceInstancePath: string; resourceName?: string }; - target: { resourceType: ResourceType; resourceInstancePath: string; resourceName?: string }; + source: { + resourceType: ResourceType; + resourceInstancePath: string; + resourceName?: string; + resourceNameIsExplicit?: boolean; + }; + target: { + resourceType: ResourceType; + resourceInstancePath: string; + resourceName?: string; + resourceNameIsExplicit?: boolean; + }; }[] = [ { title: "operations with different resource types", @@ -481,6 +519,48 @@ describe("unit tests for resource manager helpers", () => { resourceName: "NotBar", }, }, + { + title: "operations with different scopes (subscription vs tenant) and no explicit name", + source: { + resourceType: { + provider: "Microsoft.Foo", + types: ["bars"], + }, + resourceInstancePath: + "/subscriptions/{subscriptionId}/providers/Microsoft.Foo/bars/{barName}", + resourceName: "Bars", + }, + target: { + resourceType: { + provider: "Microsoft.Foo", + types: ["bars"], + }, + resourceInstancePath: "/providers/Microsoft.Foo/bars/{barName}", + resourceName: "Bars", + }, + }, + { + title: + "operations with different scopes (resource group vs subscription) and no explicit name", + source: { + resourceType: { + provider: "Microsoft.Foo", + types: ["bars"], + }, + resourceInstancePath: + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Foo/bars/{barName}", + resourceName: "Bars", + }, + target: { + resourceType: { + provider: "Microsoft.Foo", + types: ["bars"], + }, + resourceInstancePath: + "/subscriptions/{subscriptionId}/providers/Microsoft.Foo/bars/{barName}", + resourceName: "Bars", + }, + }, ]; for (const { title, source, target } of cases) { it(`does not match ${title}`, () => { @@ -3792,4 +3872,230 @@ model MoveResponse { "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Provider.B/foos/{name}", ); }); + + it("separates cross-scope LegacyOperations with default resource names into distinct resources", async () => { + const { program } = await Tester.compile(` + +using Azure.Core; + +/** Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ title: "ContosoProviderHubClient" }) +@versioned(Versions) +namespace Microsoft.ContosoProviderHub; + +/** Contoso API versions */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_20_01_preview: "2021-10-01-preview", +} + +@subscriptionResource +model SupportTicketDetails is ProxyResource { + ...ResourceNameParameter< + Resource = SupportTicketDetails, + KeyName = "supportTicketName", + SegmentName = "supportTickets", + NamePattern = "" + >; +} + +model SupportTicketProperties { + description?: string; +} + +interface Operations extends Azure.ResourceManager.Operations {} + +// Subscription-scoped operations (includes SubscriptionIdParameter) +alias SubTicketOps = Azure.ResourceManager.Legacy.LegacyOperations< + { + ...ApiVersionParameter; + ...SubscriptionIdParameter; + ...Azure.ResourceManager.Legacy.Provider; + }, + { + @segment("supportTickets") + @key + @TypeSpec.Http.path + supportTicketName: string; + } +>; + +// Tenant-scoped operations (no SubscriptionIdParameter) +alias TenantTicketOps = Azure.ResourceManager.Legacy.LegacyOperations< + { + ...ApiVersionParameter; + ...Azure.ResourceManager.Legacy.Provider; + }, + { + @segment("supportTickets") + @key + @TypeSpec.Http.path + supportTicketName: string; + } +>; + +@armResourceOperations +interface SupportTickets { + get is SubTicketOps.Read; + list is SubTicketOps.List; +} + +@armResourceOperations +interface SupportTicketsNoSubscription { + get is TenantTicketOps.Read; + list is TenantTicketOps.List; +} +`); + const provider = resolveArmResources(program); + expect(provider).toBeDefined(); + expect(provider.resources).toBeDefined(); + ok(provider.resources); + // Should produce 2 separate resources: one subscription-scoped, one tenant-scoped + expect(provider.resources).toHaveLength(2); + + const subResource = provider.resources.find( + (r) => + r.resourceInstancePath === + "/subscriptions/{subscriptionId}/providers/Microsoft.ContosoProviderHub/supportTickets/{supportTicketName}", + ); + ok(subResource); + expect(subResource.scope).toEqual("Subscription"); + + checkResolvedOperations(subResource, { + operations: { + lifecycle: { + read: [{ operationGroup: "SupportTickets", name: "get", kind: "read" }], + }, + lists: [{ operationGroup: "SupportTickets", name: "list", kind: "list" }], + }, + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["supportTickets"], + }, + resourceInstancePath: + "/subscriptions/{subscriptionId}/providers/Microsoft.ContosoProviderHub/supportTickets/{supportTicketName}", + }); + + const tenantResource = provider.resources.find( + (r) => + r.resourceInstancePath === + "/providers/Microsoft.ContosoProviderHub/supportTickets/{supportTicketName}", + ); + ok(tenantResource); + expect(tenantResource.scope).toEqual("Tenant"); + + checkResolvedOperations(tenantResource, { + operations: { + lifecycle: { + read: [{ operationGroup: "SupportTicketsNoSubscription", name: "get", kind: "read" }], + }, + lists: [{ operationGroup: "SupportTicketsNoSubscription", name: "list", kind: "list" }], + }, + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["supportTickets"], + }, + resourceInstancePath: + "/providers/Microsoft.ContosoProviderHub/supportTickets/{supportTicketName}", + }); + }); + + it("merges cross-scope LegacyOperations with explicit same resource name into one resource", async () => { + const { program } = await Tester.compile(` + +using Azure.Core; + +/** Contoso Resource Provider management API. */ +@armProviderNamespace +@service(#{ title: "ContosoProviderHubClient" }) +@versioned(Versions) +namespace Microsoft.ContosoProviderHub; + +/** Contoso API versions */ +enum Versions { + /** 2021-10-01-preview version */ + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2021_20_01_preview: "2021-10-01-preview", +} + +@subscriptionResource +model SupportTicketDetails is ProxyResource { + ...ResourceNameParameter< + Resource = SupportTicketDetails, + KeyName = "supportTicketName", + SegmentName = "supportTickets", + NamePattern = "" + >; +} + +model SupportTicketProperties { + description?: string; +} + +interface Operations extends Azure.ResourceManager.Operations {} + +// Subscription-scoped operations with explicit resource name +alias SubTicketOps = Azure.ResourceManager.Legacy.LegacyOperations< + { + ...ApiVersionParameter; + ...SubscriptionIdParameter; + ...Azure.ResourceManager.Legacy.Provider; + }, + { + @segment("supportTickets") + @key + @TypeSpec.Http.path + supportTicketName: string; + }, + ResourceName = "SupportTickets", +>; + +// Tenant-scoped operations with same explicit resource name +alias TenantTicketOps = Azure.ResourceManager.Legacy.LegacyOperations< + { + ...ApiVersionParameter; + ...Azure.ResourceManager.Legacy.Provider; + }, + { + @segment("supportTickets") + @key + @TypeSpec.Http.path + supportTicketName: string; + }, + ResourceName = "SupportTickets", +>; + +@armResourceOperations +interface SupportTickets { + get is SubTicketOps.Read; + list is SubTicketOps.List; +} + +@armResourceOperations +interface SupportTicketsNoSubscription { + get is TenantTicketOps.Read; + list is TenantTicketOps.List; +} +`); + const provider = resolveArmResources(program); + expect(provider).toBeDefined(); + expect(provider.resources).toBeDefined(); + ok(provider.resources); + // Should produce 1 merged resource since both have the same explicit resource name + expect(provider.resources).toHaveLength(1); + + const resource = provider.resources[0]; + ok(resource); + expect(resource.resourceName).toEqual("SupportTickets"); + + // Both read operations should be present + expect(resource.operations.lifecycle.read).toBeDefined(); + expect(resource.operations.lifecycle.read).toHaveLength(2); + + // Both list operations should be present + expect(resource.operations.lists).toBeDefined(); + expect(resource.operations.lists).toHaveLength(2); + }); });