Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ export function buildArmProviderSchema(
}
}

// Update the model's resourceScope based on resource scope decorator if it exists or based on the Read method's scope.
// Update the model's resourceScope based on the Read method's scope, falling back to resource scope decorator.
// This is specific to legacy resource detection
for (const [metadataKey, metadata] of resourcePathToMetadataMap) {
const modelId = metadataKey.split("|")[0];
Expand Down Expand Up @@ -989,17 +989,10 @@ function getResourceScope(
model: InputModelType,
methods?: ResourceMethod[]
): ResourceScope {
// First, check for explicit scope decorators
const decorators = model.decorators;
if (decorators?.some((d) => d.name == tenantResource)) {
return ResourceScope.Tenant;
} else if (decorators?.some((d) => d.name == subscriptionResource)) {
return ResourceScope.Subscription;
} else if (decorators?.some((d) => d.name == resourceGroupResource)) {
return ResourceScope.ResourceGroup;
}

// Fall back to Read method's scope only if no scope decorators are found
// First, try to derive scope from the Read method's operationScope.
// Methods have accurate scope derived from their actual operation paths,
// which is more reliable than model decorators when a model is shared across scopes
// (e.g., a model with @subscriptionResource used by both subscription-scoped and tenant-scoped operations).
if (methods) {
const getMethod = methods.find(
(m) => m.kind === ResourceOperationKind.Read
Expand All @@ -1009,6 +1002,16 @@ function getResourceScope(
}
}

// Fall back to explicit scope decorators when no Read method is available
const decorators = model.decorators;
if (decorators?.some((d) => d.name == tenantResource)) {
return ResourceScope.Tenant;
} else if (decorators?.some((d) => d.name == subscriptionResource)) {
return ResourceScope.Subscription;
} else if (decorators?.some((d) => d.name == resourceGroupResource)) {
return ResourceScope.ResourceGroup;
}

// Final fallback to ResourceGroup
return ResourceScope.ResourceGroup; // all the templates work as if there is a resource group decorator when there is no such decorator
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2835,6 +2835,166 @@ interface TenantTranscripts {
);
});

it("@subscriptionResource model used in tenant-scoped LegacyOperations - scope comparison", async () => {
// This test verifies that both buildArmProviderSchema (legacy detection) and
// resolveArmResources produce the same result when a model decorated with
// @subscriptionResource is used in legacy operations at both subscription and tenant scopes.
// This mirrors the Support SDK's SupportTicketDetails pattern where the same model
// is used by SupportTickets (subscription) and SupportTicketsNoSubscription (tenant).
Comment on lines +2838 to +2843
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test description says it verifies buildArmProviderSchema and resolveArmResources produce the same result, but the schema comparison is commented out and the test only asserts legacy detection behavior (plus ok(resolvedSchema)). This is misleading for future readers/triage. Consider renaming the test and updating the comment to reflect that it only validates legacy detection, or add explicit assertions for the expected resolveArmResources behavior (even if it’s currently a known gap).

Suggested change
it("@subscriptionResource model used in tenant-scoped LegacyOperations - scope comparison", async () => {
// This test verifies that both buildArmProviderSchema (legacy detection) and
// resolveArmResources produce the same result when a model decorated with
// @subscriptionResource is used in legacy operations at both subscription and tenant scopes.
// This mirrors the Support SDK's SupportTicketDetails pattern where the same model
// is used by SupportTickets (subscription) and SupportTicketsNoSubscription (tenant).
it("@subscriptionResource model used in tenant-scoped LegacyOperations - legacy detection", async () => {
// This test validates the legacy buildArmProviderSchema behavior when a model decorated
// with @subscriptionResource is used in legacy operations at both subscription and
// tenant scopes. This mirrors the Support SDK's SupportTicketDetails pattern where the
// same model is used by SupportTickets (subscription) and SupportTicketsNoSubscription
// (tenant).
//
// resolveArmResources is invoked below to ensure it runs without throwing, but we
// intentionally do not assert on its output yet. There is a known behavioral gap where
// resolveArmResources merges subscription- and tenant-scoped operations for the same
// model into fewer resources, while buildArmProviderSchema keeps them as distinct
// resources per scope. Once that gap is closed, this test should be updated to add
// explicit assertions comparing the two schemas.

Copilot uses AI. Check for mistakes.
const program = await typeSpecCompile(
`
/** A support ticket resource - decorated as @subscriptionResource */
#suppress "@azure-tools/typespec-azure-core/no-legacy-usage" "Testing cross-scope"
@subscriptionResource
model SupportTicketDetails is ProxyResource<SupportTicketProperties> {
...ResourceNameParameter<
Resource = SupportTicketDetails,
KeyName = "supportTicketName",
SegmentName = "supportTickets",
NamePattern = ""
>;
}
/** Support ticket properties */
model SupportTicketProperties {
/** Description */
description?: string;
}

// Subscription-scoped ticket operations (includes SubscriptionIdParameter)
#suppress "@azure-tools/typespec-azure-core/no-legacy-usage" "Testing cross-scope"
alias SubTicketOps = Azure.ResourceManager.Legacy.LegacyOperations<
{
...ApiVersionParameter;
...SubscriptionIdParameter;
...Azure.ResourceManager.Legacy.Provider;
},
{
@segment("supportTickets")
@key
@TypeSpec.Http.path
supportTicketName: string;
}
>;

// Tenant-scoped ticket operations (no SubscriptionIdParameter)
#suppress "@azure-tools/typespec-azure-core/no-legacy-usage" "Testing cross-scope"
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<SupportTicketDetails>;
list is SubTicketOps.List<SupportTicketDetails>;
}

@armResourceOperations
interface SupportTicketsNoSubscription {
get is TenantTicketOps.Read<SupportTicketDetails>;
list is TenantTicketOps.List<SupportTicketDetails>;
}
`,
runner
);
const context = createEmitterContext(program);
const sdkContext = await createCSharpSdkContext(context);
const root = createModel(sdkContext);

// === Test buildArmProviderSchema (legacy detection) ===
const armProviderSchema = buildArmProviderSchema(sdkContext, root);
ok(armProviderSchema);

// Legacy detection should separate these into 2 resources (one per scope)
strictEqual(
armProviderSchema.resources.length,
2,
"Legacy detection should produce 2 resources (subscription + tenant)"
);

// Find each resource by its path pattern
const subTicket = armProviderSchema.resources.find(
(r) =>
r.metadata.resourceIdPattern.includes("/subscriptions/") &&
r.metadata.resourceIdPattern.endsWith(
"/supportTickets/{supportTicketName}"
)
);
const tenantTicket = armProviderSchema.resources.find(
(r) =>
!r.metadata.resourceIdPattern.includes("/subscriptions/") &&
r.metadata.resourceIdPattern.endsWith(
"/supportTickets/{supportTicketName}"
)
);

ok(subTicket, "Should have subscription-scoped ticket");
ok(tenantTicket, "Should have tenant-scoped ticket");

// The subscription-scoped resource correctly gets "Subscription"
strictEqual(
subTicket.metadata.resourceScope,
"Subscription",
"Subscription ticket resourceScope"
);

// With method-first priority, the tenant-scoped resource correctly gets "Tenant"
// derived from the Read method's operationScope, not from the model's @subscriptionResource decorator
strictEqual(
tenantTicket.metadata.resourceScope,
"Tenant",
"Tenant ticket resourceScope (method-derived scope wins over decorator)"
);

// Verify that the methods themselves correctly have Tenant scope
for (const method of tenantTicket.metadata.methods) {
strictEqual(
method.operationScope,
"Tenant",
`Tenant ticket method ${method.kind} should have Tenant operationScope`
);
}

// Verify subscription ticket methods have Subscription scope
for (const method of subTicket.metadata.methods) {
strictEqual(
method.operationScope,
"Subscription",
`Subscription ticket method ${method.kind} should have Subscription operationScope`
);
}

// === Test resolveArmResources ===
const resolvedSchema = resolveArmResources(program, sdkContext);
ok(resolvedSchema);

// resolveArmResources merges cross-scope operations for the same model into a single resource,
// while the legacy detection correctly separates them into distinct resources per scope.
// This is a known gap in resolveArmResources for cross-scope LegacyOperations patterns.
strictEqual(
resolvedSchema.resources.length,
1,
"resolveArmResources merges cross-scope operations into 1 resource"
);

// The merged resource uses the subscription path as its resourceIdPattern
const resolvedResource = resolvedSchema.resources[0];
ok(resolvedResource);
strictEqual(
resolvedResource.metadata.resourceScope,
"Subscription",
"resolveArmResources: merged resource gets Subscription scope"
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot we should validate them by deepEqual like other tests did.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8c13b53. Replaced the manual assertions with the deepStrictEqual(normalizeSchemaForComparison(...)) pattern, commented out with a TODO since resolveArmResources merges cross-scope operations into a single resource (known gap). This matches the pattern used for the singleton resource test at lines 396-401.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot could you show me how it would fail? Which part in the result would differ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran deepStrictEqual and here are the exact differences:

Legacy (buildArmProviderSchema) produces 2 resources:

  1. Tenant resource (resourceScope: "Tenant", resourceIdPattern: "/providers/.../supportTickets/{name}") with 2 methods (get + list, both operationScope: "Tenant")
  2. Subscription resource (resourceScope: "Subscription", resourceIdPattern: "/subscriptions/{id}/providers/.../supportTickets/{name}") with 2 methods (get + list, both operationScope: "Subscription")

Resolved (resolveArmResources) produces 1 merged resource:

  • Uses subscription path as resourceIdPattern, resourceScope: "Subscription"
  • All 4 methods merged into one resource:
    • SupportTickets.getoperationScope: "Subscription"
    • SupportTicketsNoSubscription.getoperationScope: "Subscription" ❌ (should be "Tenant")
    • SupportTickets.listoperationScope: "Subscription"
    • SupportTicketsNoSubscription.listoperationScope: "Tenant"
  • The tenant get method also loses its resourceScope field

So resolveArmResources has two issues: (1) it merges cross-scope operations into a single resource instead of separating them, and (2) the tenant get method's operationScope gets incorrectly set to "Subscription" in the merged result.

});

it("name constraints with all decorators via NamePattern and direct decorators", async () => {
const program = await typeSpecCompile(
`
Expand Down