diff --git a/.chronus/changes/promote-latest-version-common-types-2026-08-20-14-00-00.md b/.chronus/changes/promote-latest-version-common-types-2026-08-20-14-00-00.md new file mode 100644 index 0000000000..39d11b95d2 --- /dev/null +++ b/.chronus/changes/promote-latest-version-common-types-2026-08-20-14-00-00.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-resource-manager" + - "@azure-tools/typespec-azure-rulesets" +--- + +Add an ARM lint rule that warns when services select or emit older ARM common-types versions instead of the latest available common-types version. diff --git a/packages/typespec-azure-resource-manager/README.md b/packages/typespec-azure-resource-manager/README.md index 985a806392..e0ea788e27 100644 --- a/packages/typespec-azure-resource-manager/README.md +++ b/packages/typespec-azure-resource-manager/README.md @@ -61,6 +61,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-action-verb`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-invalid-action-verb) | Actions must be HTTP Post or Get operations. | | [`@azure-tools/typespec-azure-resource-manager/improper-subscription-list-operation`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/improper-subscription-list-operation) | Tenant and Extension resources should not define a list by subscription operation. | | [`@azure-tools/typespec-azure-resource-manager/lro-location-header`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/lro-location-header) | A 202 response should include a Location response header. | +| [`@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-latest-version-of-common-types) | ARM services must use the latest available ARM common-types version. | | [`@azure-tools/typespec-azure-resource-manager/missing-x-ms-identifiers`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/missing-x-ms-identifiers) | Array properties should describe their identifying properties with x-ms-identifiers. Decorate the property with @OpenAPI.extension("x-ms-identifiers", #[id-prop]) where "id-prop" is a list of the names of identifying properties in the item type. | | [`@azure-tools/typespec-azure-resource-manager/no-response-body`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-response-body) | Check that the body is empty for 202 and 204 responses, and not empty for other success (2xx) responses. | | [`@azure-tools/typespec-azure-resource-manager/missing-operations-endpoint`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/missing-operations-endpoint) | Check for missing Operations interface. | diff --git a/packages/typespec-azure-resource-manager/src/linter.ts b/packages/typespec-azure-resource-manager/src/linter.ts index 671fdbea61..2ec8d236c1 100644 --- a/packages/typespec-azure-resource-manager/src/linter.ts +++ b/packages/typespec-azure-resource-manager/src/linter.ts @@ -40,6 +40,7 @@ import { secretProprule } from "./rules/secret-prop.js"; import { unsupportedTypeRule } from "./rules/unsupported-type.js"; import { useApiVersionRule } from "./rules/use-api-version.js"; import { useInterfaceRule } from "./rules/use-interface.js"; +import { useLatestVersionOfCommonTypesRule } from "./rules/use-latest-version-of-common-types.js"; import { useOperationDecoratorRule } from "./rules/use-operation-decorator.js"; import { useRelationshipRequiredPropertiesRule } from "./rules/use-relationship-required-properties.js"; import { versionProgressionRule } from "./rules/version-progression.js"; @@ -78,6 +79,7 @@ const rules = [ armResourceInvalidActionVerbRule, improperSubscriptionListOperationRule, lroLocationHeaderRule, + useLatestVersionOfCommonTypesRule, missingXmsIdentifiersRule, noResponseBodyRule, operationsInterfaceMissingRule, diff --git a/packages/typespec-azure-resource-manager/src/rules/use-latest-version-of-common-types.md b/packages/typespec-azure-resource-manager/src/rules/use-latest-version-of-common-types.md new file mode 100644 index 0000000000..24904868af --- /dev/null +++ b/packages/typespec-azure-resource-manager/src/rules/use-latest-version-of-common-types.md @@ -0,0 +1,112 @@ +--- +title: "use-latest-version-of-common-types" +--- + +```text title="Full name" +@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types +``` + +ARM services should use the latest ARM common-types version available in +`Azure.ResourceManager.CommonTypes.Versions`. This keeps TypeSpec services, +generated SDKs, and Azure tooling aligned with the current ARM common schemas. + +The rule checks the effective `@armCommonTypesVersion` on each ARM service or +service version. When the selected version is current, it also checks common +types reachable from HTTP operation parameters and payloads so older legacy +symbols are not emitted through an otherwise current API version. + +## Impact + +- **Area:** API, SDK + +Older ARM common-types versions can expose stale shared schemas or parameters in +generated API surfaces and SDKs even when newer definitions are available. + +## Incorrect + +```tsp +@armProviderNamespace +@service(#{ title: "Contoso" }) +@versioned(Versions) +@armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v3) +namespace Microsoft.Contoso; + +enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.v3) + v2024_01_01: "2024-01-01", +} +``` + +## Correct + +```tsp +@armProviderNamespace +@service(#{ title: "Contoso" }) +@versioned(Versions) +@armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v6) +namespace Microsoft.Contoso; + +enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.v6) + v2024_01_01: "2024-01-01", +} +``` + +As of August 2026, `v6` was the latest ARM common-types version. +Newer versions may exist; check `Azure.ResourceManager.CommonTypes.Versions` +for the latest version before updating a service. + +## Incorrect + +This service selects the latest common-types version but still uses a legacy +common type that resolves to an older common-types file. + +```tsp +@armProviderNamespace +@service(#{ title: "Contoso" }) +@versioned(Versions) +@armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v6) +namespace Microsoft.Contoso; + +enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.v6) + v2024_01_01: "2024-01-01", +} + +@route("/identity") +@get +op getIdentity(): Azure.ResourceManager.Legacy.ManagedServiceIdentityV4; +``` + +## Correct + +Use the equivalent common type supported by the selected latest common-types +version, or remove the legacy reference when the API shape no longer needs it. + +```tsp +@armProviderNamespace +@service(#{ title: "Contoso" }) +@versioned(Versions) +@armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v6) +namespace Microsoft.Contoso; + +enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.v6) + v2024_01_01: "2024-01-01", +} + +@route("/identity") +@get +op getIdentity(): Azure.ResourceManager.CommonTypes.ManagedServiceIdentity; +``` + +## LintDiff Equivalent + +This rule corresponds to the LintDiff rule +[LatestVersionOfCommonTypesMustBeUsed](https://github.com/Azure/azure-openapi-validator/blob/main/docs/latest-version-of-common-types-must-be-used.md). + +## Suppression + +Suppress only when an API must intentionally emit an older ARM common-types +schema for compatibility and the service team has accepted the API and SDK +impact. diff --git a/packages/typespec-azure-resource-manager/src/rules/use-latest-version-of-common-types.ts b/packages/typespec-azure-resource-manager/src/rules/use-latest-version-of-common-types.ts new file mode 100644 index 0000000000..02c58ff074 --- /dev/null +++ b/packages/typespec-azure-resource-manager/src/rules/use-latest-version-of-common-types.ts @@ -0,0 +1,431 @@ +import { + compilerAssert, + createRule, + fileRef, + getLifecycleVisibilityEnum, + getService, + getVisibilityForClass, + isArrayModelType, + listServices, + paramMessage, + walkPropertiesInherited, + type Enum, + type EnumMember, + type Model, + type ModelProperty, + type Namespace, + type Operation, + type Program, + type Service, + type Type, + type Union, +} from "@typespec/compiler"; +import { unsafe_mutateSubgraphWithNamespace } from "@typespec/compiler/experimental"; +import { + Visibility, + createMetadataInfo, + getHttpService, + resolveRequestVisibility, + type HttpOperation, +} from "@typespec/http"; +import { getVersioningMutators } from "@typespec/versioning"; +import { + getArmCommonTypeOpenAPIRef, + getArmCommonTypesVersion, + getArmCommonTypesVersions, + isArmCommonType, +} from "../common-types.js"; +import { getArmProviderNamespace } from "../namespace.js"; + +export const useLatestVersionOfCommonTypesRule = createRule({ + name: "use-latest-version-of-common-types", + docs: fileRef.fromPackageRoot("src/rules/use-latest-version-of-common-types.md"), + description: "ARM services must use the latest available ARM common-types version.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/use-latest-version-of-common-types", + messages: { + default: paramMessage`Use the latest ARM common-types version '${"latestVersion"}' instead of '${"currentVersion"}'.`, + reference: paramMessage`This API version already selects the latest ARM common-types version '${"latestVersion"}', but the common-type ${"referenceKind"} '${"referenceName"}' resolves to '${"fileName"}' version '${"currentVersion"}'. Replace the TypeSpec usage that produces this legacy reference with a common type supported in '${"latestVersion"}'.`, + }, + create(context) { + return { + root: (program) => { + const latestVersion = tryGetLatestArmCommonTypesVersion(program); + if (latestVersion === undefined) { + return; + } + + for (const service of listServices(program)) { + if (!getArmProviderNamespace(program, service.type)) { + continue; + } + + const compilerService = getService(program, service.type); + if (compilerService === undefined) { + continue; + } + + const versioning = getVersioningMutators(program, service.type); + if (versioning?.kind === "versioned") { + for (const snapshot of versioning.snapshots) { + const currentVersion = + getArmCommonTypesVersion(program, snapshot.version.enumMember) ?? + getArmCommonTypesVersion(program, service.type); + if (isOutdated(currentVersion, latestVersion)) { + reportOutdatedSelection( + context, + snapshot.version.enumMember, + currentVersion, + latestVersion, + ); + continue; + } + if (currentVersion === undefined) { + continue; + } + + const projected = unsafe_mutateSubgraphWithNamespace( + program, + [snapshot.mutator], + service.type, + ); + compilerAssert( + projected.type.kind === "Namespace", + "A versioned service must project to a namespace.", + ); + const projectedService = getService(program, projected.type) ?? { + type: projected.type, + }; + const [httpService] = getHttpService(program, projected.type); + reportOutdatedUsages( + context, + collectCommonTypeUsages(program, httpService.operations), + projectedService, + snapshot.version.value, + latestVersion, + ); + } + continue; + } + + let analyzedService = service.type; + if (versioning?.kind === "transient") { + const projected = unsafe_mutateSubgraphWithNamespace( + program, + [versioning.mutator], + service.type, + ); + compilerAssert( + projected.type.kind === "Namespace", + "A transiently versioned service must project to a namespace.", + ); + analyzedService = projected.type; + } + + const currentVersion = getArmCommonTypesVersion(program, service.type); + if (isOutdated(currentVersion, latestVersion)) { + reportOutdatedSelection(context, service.type, currentVersion, latestVersion); + continue; + } + if (currentVersion === undefined) { + continue; + } + + const [httpService] = getHttpService(program, analyzedService); + reportOutdatedUsages( + context, + collectCommonTypeUsages(program, httpService.operations), + getService(program, analyzedService) ?? compilerService, + undefined, + latestVersion, + ); + } + }, + }; + }, +}); + +function tryGetLatestArmCommonTypesVersion(program: Program): string | undefined { + const allVersions = getArmCommonTypesVersions(program)?.allVersions; + if (allVersions === undefined || allVersions.length === 0) { + return undefined; + } + + return allVersions.reduce((latest, version) => + getVersionNumber(version.name) > getVersionNumber(latest.name) ? version : latest, + ).name; +} + +function getVersionNumber(version: string): number { + const match = /^v(\d+)$/i.exec(version); + return match ? Number(match[1]) : Number.NEGATIVE_INFINITY; +} + +function isOutdated( + currentVersion: string | undefined, + latestVersion: string, +): currentVersion is string { + return currentVersion !== undefined && currentVersion !== latestVersion; +} + +function reportOutdatedSelection( + context: Parameters[0], + target: Namespace | EnumMember, + currentVersion: string, + latestVersion: string, +): void { + context.reportDiagnostic({ + target, + format: { + currentVersion, + latestVersion, + fileName: "common-types", + referenceKind: "selection", + referenceName: "common-types", + }, + }); +} + +interface CommonTypeUsage { + target: ModelProperty | Operation; + type: Model | ModelProperty | Enum | Union; +} + +interface PayloadContext { + visibility: Visibility; + inExplicitBody: boolean; +} + +function collectCommonTypeUsages( + program: Program, + operations: readonly HttpOperation[], +): CommonTypeUsage[] { + const usages: CommonTypeUsage[] = []; + const seenTypes = new Map>>(); + const seenUsages = new Map>(); + const metadataInfo = createMetadataInfo(program, { + canonicalVisibility: Visibility.Read, + canShareProperty: (property) => canSharePropertyUsingReadonlyOrXmsMutability(program, property), + }); + + const addUsage = ( + type: Model | ModelProperty | Enum | Union, + target: ModelProperty | Operation, + ) => { + if (!isArmCommonType(type)) { + return; + } + + let targetTypes = seenUsages.get(target); + if (targetTypes === undefined) { + targetTypes = new Set(); + seenUsages.set(target, targetTypes); + } + + if (!targetTypes.has(type)) { + targetTypes.add(type); + usages.push({ target, type }); + } + }; + + const visitType = ( + type: Type, + target: ModelProperty | Operation, + payloadContext: PayloadContext, + ) => { + let targetTypes = seenTypes.get(target); + if (targetTypes === undefined) { + targetTypes = new Map(); + seenTypes.set(target, targetTypes); + } + + let typeContexts = targetTypes.get(type); + if (typeContexts === undefined) { + typeContexts = new Set(); + targetTypes.set(type, typeContexts); + } + + const contextIdentity = `${payloadContext.visibility}:${payloadContext.inExplicitBody}`; + if (typeContexts.has(contextIdentity)) { + return; + } + typeContexts.add(contextIdentity); + + switch (type.kind) { + case "Model": + addUsage(type, target); + if (type.indexer) { + visitType(type.indexer.value, target, { + ...payloadContext, + visibility: isArrayModelType(type) + ? payloadContext.visibility | Visibility.Item + : payloadContext.visibility, + }); + } + for (const property of walkPropertiesInherited(type)) { + if ( + !metadataInfo.isPayloadProperty( + property, + payloadContext.visibility, + payloadContext.inExplicitBody, + ) + ) { + continue; + } + + addPropertyUsages(property, target, payloadContext); + } + break; + case "ModelProperty": + addPropertyUsages(type, target, payloadContext); + break; + case "Enum": + case "Union": + addUsage(type, target); + if (type.kind === "Union") { + for (const variant of type.variants.values()) { + visitType(variant.type, target, payloadContext); + } + } + break; + case "Tuple": + for (const value of type.values) { + visitType(value, target, { + ...payloadContext, + visibility: payloadContext.visibility | Visibility.Item, + }); + } + break; + } + }; + + function addPropertyUsages( + property: ModelProperty, + target: ModelProperty | Operation, + payloadContext: PayloadContext, + ) { + for ( + let current: ModelProperty | undefined = property; + current !== undefined; + current = current.sourceProperty + ) { + addUsage(current, target); + visitType(current.type, target, payloadContext); + } + } + + for (const httpOperation of operations) { + const requestContext = { + visibility: resolveRequestVisibility(program, httpOperation.operation, httpOperation.verb), + inExplicitBody: false, + }; + for (const parameter of httpOperation.parameters.properties) { + addPropertyUsages(parameter.property, httpOperation.operation, requestContext); + } + if (httpOperation.parameters.body) { + const body = httpOperation.parameters.body; + visitType(body.type, httpOperation.operation, { + visibility: requestContext.visibility, + inExplicitBody: + body.bodyKind === "single" && body.isExplicit && body.containsMetadataAnnotations, + }); + } + for (const response of httpOperation.responses) { + for (const responseContent of response.responses) { + if (responseContent.body) { + const body = responseContent.body; + visitType(body.type, httpOperation.operation, { + visibility: Visibility.Read, + inExplicitBody: + body.bodyKind === "single" && body.isExplicit && body.containsMetadataAnnotations, + }); + } + } + } + } + + return usages; +} + +function canSharePropertyUsingReadonlyOrXmsMutability( + program: Program, + property: ModelProperty, +): boolean { + const sharedVisibilities = new Set(["Read", "Create", "Update"]); + const lifecycle = getLifecycleVisibilityEnum(program); + const visibilities = getVisibilityForClass(program, property, lifecycle); + if (visibilities.size !== lifecycle.members.size) { + for (const visibility of visibilities) { + if (!sharedVisibilities.has(visibility.name)) { + return false; + } + } + } + + return visibilities.size !== 0; +} + +function reportOutdatedUsages( + context: Parameters[0], + usages: CommonTypeUsage[], + service: Service, + apiVersion: string | undefined, + latestVersion: string, +): void { + const reported = new Map>(); + + for (const usage of usages) { + const reference = getArmCommonTypeOpenAPIRef(context.program, usage.type, { + service, + version: apiVersion, + }); + const parsedReference = parseCommonTypesReference(reference); + if (parsedReference === undefined || parsedReference.version === latestVersion) { + continue; + } + + const identity = `${parsedReference.fileName}\0${parsedReference.version}\0${parsedReference.referenceKind}\0${parsedReference.referenceName}`; + let targetReferences = reported.get(usage.target); + if (targetReferences === undefined) { + targetReferences = new Set(); + reported.set(usage.target, targetReferences); + } + if (targetReferences.has(identity)) { + continue; + } + targetReferences.add(identity); + + context.reportDiagnostic({ + messageId: "reference", + target: usage.target, + format: { + currentVersion: parsedReference.version, + latestVersion, + fileName: parsedReference.fileName, + referenceKind: parsedReference.referenceKind, + referenceName: parsedReference.referenceName, + }, + }); + } +} + +function parseCommonTypesReference(reference: string | undefined): + | { + version: string; + fileName: string; + referenceKind: string; + referenceName: string; + } + | undefined { + const match = reference?.match( + /(?:resource-management\/|\{arm-types-dir\}\/)(v\d+)\/([^/#]+\.json)#\/([^/]+)\/([^/]+)$/i, + ); + return match + ? { + version: match[1].toLowerCase(), + fileName: match[2].toLowerCase(), + referenceKind: match[3].replace(/s$/i, "").toLowerCase(), + referenceName: decodeURIComponent(match[4]), + } + : undefined; +} diff --git a/packages/typespec-azure-resource-manager/test/rules/use-latest-version-of-common-types.test.ts b/packages/typespec-azure-resource-manager/test/rules/use-latest-version-of-common-types.test.ts new file mode 100644 index 0000000000..02c0dd6d19 --- /dev/null +++ b/packages/typespec-azure-resource-manager/test/rules/use-latest-version-of-common-types.test.ts @@ -0,0 +1,292 @@ +import { Tester } from "#test/tester.js"; +import { + type LinterRuleTester, + type TesterInstance, + createLinterRuleTester, +} from "@typespec/compiler/testing"; +import { beforeEach, it } from "vitest"; + +import { useLatestVersionOfCommonTypesRule } from "../../src/rules/use-latest-version-of-common-types.js"; + +let runner: TesterInstance; +let tester: LinterRuleTester; + +beforeEach(async () => { + runner = await Tester.createInstance(); + tester = createLinterRuleTester( + runner, + useLatestVersionOfCommonTypesRule, + "@azure-tools/typespec-azure-resource-manager", + ); +}); + +const latestVersion = "v6"; + +const serviceHeader = ( + namespaceCommonTypesVersion: string, + versionMemberCommonTypesVersion = namespaceCommonTypesVersion, + extraVersions = "", +) => ` + @armProviderNamespace + @service(#{ title: "Test Service" }) + @versioned(Versions) + @armCommonTypesVersion(CommonTypes.Versions.${namespaceCommonTypesVersion}) + namespace Microsoft.TestService; + + enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.${versionMemberCommonTypesVersion}) + v2024_01_01: "2024-01-01", + ${extraVersions} + } +`; + +const widgetResource = ` + model Widget is TrackedResource { + @key("widgetName") + @segment("widgets") + @doc("The name of the widget") + @path + @pattern("^[a-zA-Z0-9_-]+$") + name: string; + } + + @doc("Widget resource properties.") + model WidgetProperties { + @doc("Description") + description?: string; + + @doc("Resource provisioning state") + @visibility(Lifecycle.Read) + provisioningState?: ResourceProvisioningState; + } + + interface Operations extends Azure.ResourceManager.Operations {} + + @armResourceOperations + interface Widgets { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + update is ArmResourcePatchAsync; + delete is ArmResourceDeleteWithoutOkAsync; + listByResourceGroup is ArmResourceListByParent; + } +`; + +it("emits diagnostic when a versioned ARM service selects an older namespace common-types version", async () => { + await tester.expect(`${serviceHeader("v3")} ${widgetResource}`).toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `Use the latest ARM common-types version '${latestVersion}' instead of 'v3'.`, + }); +}); + +it("does not emit when a versioned ARM service selects the latest namespace common-types version", async () => { + await tester.expect(`${serviceHeader(latestVersion)} ${widgetResource}`).toBeValid(); +}); + +it("emits diagnostic when a version enum member overrides the namespace back to an older common-types version", async () => { + await tester + .expect( + ` + ${serviceHeader(latestVersion, "v3")} + + @@armCommonTypesVersion(Versions.v2024_01_01, Azure.ResourceManager.CommonTypes.Versions.v3); + + ${widgetResource} + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `Use the latest ARM common-types version '${latestVersion}' instead of 'v3'.`, + }); +}); + +it("does not emit when a version enum member overrides an older namespace to the latest common-types version", async () => { + await tester + .expect( + ` + ${serviceHeader("v3", latestVersion)} + + @@armCommonTypesVersion( + Versions.v2024_01_01, + Azure.ResourceManager.CommonTypes.Versions.${latestVersion} + ); + + ${widgetResource} + `, + ) + .toBeValid(); +}); + +it("emits diagnostic when a latest-version service uses a legacy common parameter", async () => { + await tester + .expect( + ` + ${serviceHeader(latestVersion)} + + @route("/subscriptions/{subscriptionId}/providers/Microsoft.TestService") + interface Operations { + @get + @route("/locations/{location}/widgets") + listByLocation( + ...SubscriptionIdParameter, + ...LocationParameter, + ): { + @body body: WidgetListResult; + }; + } + + model WidgetListResult { + value: Widget[]; + } + + model Widget { + name: string; + } + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `This API version already selects the latest ARM common-types version '${latestVersion}', but the common-type parameter 'LocationParameter' resolves to 'types.json' version 'v5'. Replace the TypeSpec usage that produces this legacy reference with a common type supported in '${latestVersion}'.`, + }); +}); + +it("emits diagnostic when a latest-version service uses a legacy common model", async () => { + await tester + .expect( + ` + ${serviceHeader(latestVersion)} + + model Widget is TrackedResource { + ...Azure.ResourceManager.Legacy.ManagedServiceIdentityV4Property; + + @key("widgetName") + @segment("widgets") + @doc("The name of the widget") + @path + @pattern("^[a-zA-Z0-9_-]+$") + name: string; + } + + @doc("Widget resource properties.") + model WidgetProperties { + @doc("Widget description.") + description?: string; + } + + interface Operations extends Azure.ResourceManager.Operations {} + + @armResourceOperations + interface Widgets { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceAsync; + } + `, + ) + .toEmitDiagnostics([ + { + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `This API version already selects the latest ARM common-types version '${latestVersion}', but the common-type definition 'ManagedServiceIdentity' resolves to 'managedidentity.json' version 'v4'. Replace the TypeSpec usage that produces this legacy reference with a common type supported in '${latestVersion}'.`, + }, + { + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `This API version already selects the latest ARM common-types version '${latestVersion}', but the common-type definition 'ManagedServiceIdentity' resolves to 'managedidentity.json' version 'v4'. Replace the TypeSpec usage that produces this legacy reference with a common type supported in '${latestVersion}'.`, + }, + ]); +}); + +it("reports a versioned legacy property only for the projected API version that contains it", async () => { + await tester + .expect( + ` + ${serviceHeader( + latestVersion, + latestVersion, + ` + @useDependency(Azure.ResourceManager.CommonTypes.Versions.${latestVersion}) + v2025_01_01: "2025-01-01", + `, + )} + + model IdentityResult { + @added(Versions.v2025_01_01) + identity?: Azure.ResourceManager.Legacy.ManagedServiceIdentityV4; + } + + @route("/identity") + @get + op getIdentity(): IdentityResult; + `, + ) + .toEmitDiagnostics({ + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `This API version already selects the latest ARM common-types version '${latestVersion}', but the common-type definition 'ManagedServiceIdentity' resolves to 'managedidentity.json' version 'v4'. Replace the TypeSpec usage that produces this legacy reference with a common type supported in '${latestVersion}'.`, + }); +}); + +it("reports each operation that emits the same legacy common type reference", async () => { + await tester + .expect( + ` + ${serviceHeader(latestVersion)} + + @route("/first") + @get + op getFirst(): Azure.ResourceManager.Legacy.ManagedServiceIdentityV4; + + @route("/second") + @get + op getSecond(): Azure.ResourceManager.Legacy.ManagedServiceIdentityV4; + `, + ) + .toEmitDiagnostics([ + { + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `This API version already selects the latest ARM common-types version '${latestVersion}', but the common-type definition 'ManagedServiceIdentity' resolves to 'managedidentity.json' version 'v4'. Replace the TypeSpec usage that produces this legacy reference with a common type supported in '${latestVersion}'.`, + }, + { + code: "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types", + message: `This API version already selects the latest ARM common-types version '${latestVersion}', but the common-type definition 'ManagedServiceIdentity' resolves to 'managedidentity.json' version 'v4'. Replace the TypeSpec usage that produces this legacy reference with a common type supported in '${latestVersion}'.`, + }, + ]); +}); + +it("does not report legacy common types excluded by request or response payload visibility", async () => { + await tester + .expect( + ` + ${serviceHeader(latestVersion)} + + model UpdateRequest { + @visibility(Lifecycle.Read) + readOnlyIdentity?: Azure.ResourceManager.Legacy.ManagedServiceIdentityV4; + + records?: Record; + description?: string; + } + + model RecordValue { + @header + legacyHeader?: Azure.ResourceManager.Legacy.ManagedServiceIdentityV4; + + description?: string; + } + + model ReadResponse { + @visibility(Lifecycle.Delete) + deleteOnlyIdentity?: Azure.ResourceManager.Legacy.ManagedServiceIdentityV4; + + description?: string; + } + + @route("/widgets/{widgetName}") + interface Widgets { + @patch + update(@path widgetName: string, @bodyRoot body: UpdateRequest): void; + + @get + read(@path widgetName: string): ReadResponse; + } + `, + ) + .toBeValid(); +}); diff --git a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts index 1167301d2f..5802cfbed7 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts @@ -62,6 +62,8 @@ export default { "@azure-tools/typespec-azure-resource-manager/no-override-props": true, "@azure-tools/typespec-azure-resource-manager/no-empty-model": true, "@azure-tools/typespec-azure-resource-manager/arm-common-types-version": true, + // Disabled for staged rollout: existing Azure specs and samples still use older ARM common-types versions. + "@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types": false, "@azure-tools/typespec-azure-resource-manager/arm-agent-base-type-child-resources": true, "@azure-tools/typespec-azure-resource-manager/arm-agent-base-type-lifecycle-operations": true, "@azure-tools/typespec-azure-resource-manager/use-relationship-required-properties": true, diff --git a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md index 8c63e5f5c0..694156d11b 100644 --- a/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md +++ b/website/src/content/docs/docs/libraries/azure-resource-manager/reference/linter.md @@ -55,6 +55,7 @@ Available ruleSets: | [`@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-action-verb`](../rules/arm-resource-invalid-action-verb.md) | Actions must be HTTP Post or Get operations. | | [`@azure-tools/typespec-azure-resource-manager/improper-subscription-list-operation`](../rules/improper-subscription-list-operation.md) | Tenant and Extension resources should not define a list by subscription operation. | | [`@azure-tools/typespec-azure-resource-manager/lro-location-header`](../rules/lro-location-header.md) | A 202 response should include a Location response header. | +| [`@azure-tools/typespec-azure-resource-manager/use-latest-version-of-common-types`](../rules/use-latest-version-of-common-types.md) | ARM services must use the latest available ARM common-types version. | | [`@azure-tools/typespec-azure-resource-manager/missing-x-ms-identifiers`](../rules/missing-x-ms-identifiers.md) | Array properties should describe their identifying properties with x-ms-identifiers. Decorate the property with @OpenAPI.extension("x-ms-identifiers", #[id-prop]) where "id-prop" is a list of the names of identifying properties in the item type. | | [`@azure-tools/typespec-azure-resource-manager/no-response-body`](../rules/no-response-body.md) | Check that the body is empty for 202 and 204 responses, and not empty for other success (2xx) responses. | | [`@azure-tools/typespec-azure-resource-manager/missing-operations-endpoint`](../rules/missing-operations-endpoint.md) | Check for missing Operations interface. |