From 605c34a2ab8ae2b565c6a2cf69b87d00939a22f5 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 28 Aug 2026 10:24:49 +0800 Subject: [PATCH 1/6] Promote GuidUsage to ARM no-uuid rule Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../changes/promote-guid-usage-2026-08-28.md | 8 + .../typespec-azure-resource-manager/README.md | 1 + .../src/linter.ts | 2 + .../src/rules/no-uuid.md | 45 +++ .../src/rules/no-uuid.ts | 280 ++++++++++++++++++ .../test/rules/no-uuid.test.ts | 266 +++++++++++++++++ .../src/rulesets/resource-manager.ts | 1 + .../reference/linter.md | 1 + 8 files changed, 604 insertions(+) create mode 100644 .chronus/changes/promote-guid-usage-2026-08-28.md create mode 100644 packages/typespec-azure-resource-manager/src/rules/no-uuid.md create mode 100644 packages/typespec-azure-resource-manager/src/rules/no-uuid.ts create mode 100644 packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts diff --git a/.chronus/changes/promote-guid-usage-2026-08-28.md b/.chronus/changes/promote-guid-usage-2026-08-28.md new file mode 100644 index 0000000000..9930a672e7 --- /dev/null +++ b/.chronus/changes/promote-guid-usage-2026-08-28.md @@ -0,0 +1,8 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-resource-manager" + - "@azure-tools/typespec-azure-rulesets" +--- + +Add the `no-uuid` ARM lint rule, migrated from the Swagger `GuidUsage` validator rule and disabled by default in the resource-manager ruleset. diff --git a/packages/typespec-azure-resource-manager/README.md b/packages/typespec-azure-resource-manager/README.md index 985a806392..b71d605c5c 100644 --- a/packages/typespec-azure-resource-manager/README.md +++ b/packages/typespec-azure-resource-manager/README.md @@ -63,6 +63,7 @@ Available ruleSets: | [`@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/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/no-uuid`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-uuid) | ARM APIs should avoid UUID-typed schemas unless they have explicit Azure API review approval. | | [`@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. | | [`@azure-tools/typespec-azure-resource-manager/patch-envelope`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/patch-envelope) | Patch envelope properties should match the resource properties. | | [`@azure-tools/typespec-azure-resource-manager/arm-resource-patch`](https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/arm-resource-patch) | Validate ARM PATCH operations. | diff --git a/packages/typespec-azure-resource-manager/src/linter.ts b/packages/typespec-azure-resource-manager/src/linter.ts index 671fdbea61..67dcb7e203 100644 --- a/packages/typespec-azure-resource-manager/src/linter.ts +++ b/packages/typespec-azure-resource-manager/src/linter.ts @@ -32,6 +32,7 @@ import { noOverridePropsRule } from "./rules/no-override-props.js"; import { noReservedResourcePropertyRule } from "./rules/no-reserved-resource-property.js"; import { deleteOperationMissingRule } from "./rules/no-resource-delete-operation.js"; import { noResponseBodyRule } from "./rules/no-response-body.js"; +import { noUuidRule } from "./rules/no-uuid.js"; import { operationsInterfaceMissingRule } from "./rules/operations-interface-missing.js"; import { patchEnvelopePropertiesRules } from "./rules/patch-envelope-properties.js"; import { resourceNameRule } from "./rules/resource-name.js"; @@ -80,6 +81,7 @@ const rules = [ lroLocationHeaderRule, missingXmsIdentifiersRule, noResponseBodyRule, + noUuidRule, operationsInterfaceMissingRule, patchEnvelopePropertiesRules, patchOperationsRule, diff --git a/packages/typespec-azure-resource-manager/src/rules/no-uuid.md b/packages/typespec-azure-resource-manager/src/rules/no-uuid.md new file mode 100644 index 0000000000..b2911cdcf5 --- /dev/null +++ b/packages/typespec-azure-resource-manager/src/rules/no-uuid.md @@ -0,0 +1,45 @@ +--- +title: "no-uuid" +--- + +```text title="Full name" +@azure-tools/typespec-azure-resource-manager/no-uuid +``` + +Avoid UUID-typed schemas in Azure Resource Manager APIs unless their use has explicit Azure API review approval. + +## Impact + +- **Area:** API, SDK + +UUIDs are difficult for customers to create, recognize, and troubleshoot. Prefer stable, human-readable identifiers that follow the resource's naming constraints. UUID wire types also become language-specific UUID types in generated SDKs, which can make an API harder to use consistently across languages. + +The rule checks UUID model properties, HTTP parameters, request and response bodies, response headers, custom scalar aliases, and container types. It also checks UUID formats applied directly with `@format("uuid")`. + +## Incorrect + +```tsp +@armProviderNamespace +namespace Microsoft.Contoso; + +model WidgetProperties { + id: Azure.Core.uuid; +} +``` + +## Correct + +```tsp +@armProviderNamespace +namespace Microsoft.Contoso; + +model WidgetProperties { + id: string; +} +``` + +If a UUID is required, obtain Azure API review approval and suppress the rule at the authored declaration with the approval context. + +## LintDiff Equivalent + +This rule corresponds to the Swagger validator rule [GuidUsage](https://github.com/Azure/azure-openapi-validator/blob/6243cb01c16c7535cd3b8df6f45fbeb3c095ed7f/docs/guid-usage.md). diff --git a/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts new file mode 100644 index 0000000000..f2d38d5231 --- /dev/null +++ b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts @@ -0,0 +1,280 @@ +import { + type ArrayModelType, + type Model, + type ModelProperty, + type Namespace, + type Operation, + type Program, + type RecordModelType, + type Type, + createRule, + fileRef, + getFormat, + getLocationContext, + getSourceLocation, + isArrayModelType, + isRecordModelType, + walkPropertiesInherited, +} from "@typespec/compiler"; +import { getAllHttpServices } from "@typespec/http"; + +import { getArmProviderNamespace } from "../namespace.js"; +import { getArmResources } from "../resource.js"; + +export const noUuidRule = createRule({ + name: "no-uuid", + docs: fileRef.fromPackageRoot("src/rules/no-uuid.md"), + description: + "ARM APIs should avoid UUID-typed schemas unless they have explicit Azure API review approval.", + severity: "warning", + url: "https://azure.github.io/typespec-azure/docs/libraries/azure-resource-manager/rules/no-uuid", + messages: { + default: + "UUID usage is not recommended. If UUIDs are required in your service, get sign-off from the Azure API review board.", + }, + create(context) { + const reportedTargets = new Set(); + const [services] = getAllHttpServices(context.program); + const armServices = services.filter((service) => + getArmProviderNamespace(context.program, service.namespace), + ); + const resourceKeyByOperation = getResourceKeyByOperation(context.program); + + return { + modelProperty: (property) => { + const model = property.model; + const modelNamespace = model?.namespace; + if ( + !model?.name || + modelNamespace === undefined || + !armServices.some((service) => isWithinNamespace(modelNamespace, service.namespace)) + ) { + return; + } + + const target = getProjectProperty(context.program, property); + if (target !== undefined) { + reportUuidUsage( + context, + property.type, + target, + reportedTargets, + new Set(), + true, + property, + ); + } + }, + root: () => { + for (const service of armServices) { + for (const httpOperation of service.operations) { + const operation = httpOperation.operation; + for (const parameter of httpOperation.parameters.parameters) { + const target = + parameter.param.name === resourceKeyByOperation.get(operation) + ? operation + : getProjectProperty(context.program, parameter.param); + if (target !== undefined) { + if (getFormat(context.program, parameter.param) === "uuid") { + reportTarget(context, target, reportedTargets); + } else { + reportUuidUsage(context, parameter.param.type, target, reportedTargets); + } + } + } + + const requestBody = httpOperation.parameters.body; + if (requestBody !== undefined) { + reportUuidUsage( + context, + requestBody.type, + getPayloadTarget(context.program, requestBody.property, operation), + reportedTargets, + ); + } + + for (const response of httpOperation.responses) { + for (const content of response.responses) { + if (content.body !== undefined) { + reportUuidUsage( + context, + content.body.type, + getPayloadTarget(context.program, content.body.property, operation), + reportedTargets, + ); + } + + for (const header of Object.values(content.headers ?? {})) { + const target = getProjectProperty(context.program, header); + if (target !== undefined) { + reportUuidUsage(context, header.type, target, reportedTargets); + } + } + } + } + } + } + }, + }; + }, +}); + +function getResourceKeyByOperation(program: Program): Map { + const result = new Map(); + for (const resource of getArmResources(program)) { + if (resource.keyName === undefined) { + continue; + } + + const operations = [ + ...Object.values(resource.operations.lifecycle), + ...Object.values(resource.operations.lists), + ...Object.values(resource.operations.actions), + ]; + for (const operation of operations) { + if (operation !== undefined) { + result.set(operation.operation, resource.keyName); + } + } + } + return result; +} + +function isWithinNamespace(namespace: Namespace, ancestor: Namespace): boolean { + for (let current: Namespace | undefined = namespace; current; current = current.namespace) { + if (current === ancestor) { + return true; + } + } + return false; +} + +function reportUuidUsage( + context: Parameters[0], + type: Type, + target: ModelProperty | Operation, + reportedTargets: Set, + seen = new Set(), + canReportTarget = true, + formatSource?: ModelProperty, +): void { + const formattedProperty = formatSource ?? (target.kind === "ModelProperty" ? target : undefined); + if ( + formattedProperty !== undefined && + getFormat(context.program, formattedProperty) === "uuid" && + canReportTarget && + isProjectDeclaration(context.program, target) + ) { + reportTarget(context, target, reportedTargets); + } + + if (seen.has(type)) { + return; + } + + seen.add(type); + + switch (type.kind) { + case "Scalar": + if (getFormat(context.program, type) === "uuid") { + if (canReportTarget && isProjectDeclaration(context.program, target)) { + reportTarget(context, target, reportedTargets); + } + } else if (type.baseScalar !== undefined) { + reportUuidUsage(context, type.baseScalar, target, reportedTargets, seen, canReportTarget); + } + return; + case "Model": + if (isContainerModel(type)) { + reportUuidUsage( + context, + type.indexer.value, + target, + reportedTargets, + seen, + canReportTarget, + ); + return; + } + for (const property of walkPropertiesInherited(type)) { + const propertyTarget = getProjectProperty(context.program, property); + reportUuidUsage( + context, + property.type, + propertyTarget ?? target, + reportedTargets, + new Set(seen), + propertyTarget !== undefined, + property, + ); + } + return; + case "Tuple": + for (const value of type.values) { + reportUuidUsage(context, value, target, reportedTargets, new Set(seen), canReportTarget); + } + return; + case "Union": + for (const variant of type.variants.values()) { + reportUuidUsage( + context, + variant.type, + target, + reportedTargets, + new Set(seen), + canReportTarget, + ); + } + return; + default: + return; + } +} + +function reportTarget( + context: Parameters[0], + target: ModelProperty | Operation, + reportedTargets: Set, +): void { + if (!reportedTargets.has(target)) { + reportedTargets.add(target); + context.reportDiagnostic({ target }); + } +} + +function isContainerModel(model: Model): model is ArrayModelType | RecordModelType { + return isArrayModelType(model) || isRecordModelType(model); +} + +function getPayloadTarget( + program: Program, + property: ModelProperty | undefined, + operation: Operation, +): ModelProperty | Operation { + return property === undefined ? operation : (getProjectProperty(program, property) ?? operation); +} + +function getProjectProperty(program: Program, property: ModelProperty): ModelProperty | undefined { + let source = property; + while (source.sourceProperty !== undefined) { + source = source.sourceProperty; + } + return isProjectDeclaration(program, source) ? source : undefined; +} + +function isProjectDeclaration( + program: Program, + declaration: Model | ModelProperty | Operation, +): boolean { + if (getLocationContext(program, declaration).type === "project") { + return true; + } + + if (declaration.node === undefined) { + return false; + } + + const path = getSourceLocation(declaration.node).file.path.replaceAll("\\", "/"); + const projectRoot = program.projectRoot.replaceAll("\\", "/").replace(/\/$/, ""); + return path === projectRoot || path.startsWith(`${projectRoot}/`); +} diff --git a/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts b/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts new file mode 100644 index 0000000000..73648fdb13 --- /dev/null +++ b/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts @@ -0,0 +1,266 @@ +import { Tester } from "#test/tester.js"; +import { + type LinterRuleTester, + type TesterInstance, + createLinterRuleTester, +} from "@typespec/compiler/testing"; +import { beforeEach, it } from "vitest"; + +import { noUuidRule } from "../../src/rules/no-uuid.js"; + +let tester: LinterRuleTester; + +beforeEach(async () => { + const runner: TesterInstance = await Tester.createInstance(); + tester = createLinterRuleTester( + runner, + noUuidRule, + "@azure-tools/typespec-azure-resource-manager", + ); +}); + +function inArmService(code: string): string { + return ` + @armProviderNamespace + @service(#{ title: "Test service" }) + namespace Microsoft.Test { + ${code} + } + `; +} + +function versionedArmService(resourceName: string): string { + return ` + @armProviderNamespace + @service(#{ title: "Test service" }) + @versioned(Versions) + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v6) + namespace Microsoft.Test; + + enum Versions { + @useDependency(Azure.ResourceManager.CommonTypes.Versions.v6) + v2024_01_01: "2024-01-01", + } + + model Widget is TrackedResource { + ...ResourceNameParameter< + Resource = Widget, + KeyName = "widgetName", + SegmentName = "widgets", + ${resourceName} + NamePattern = "" + >; + } + + model WidgetProperties { + @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; + } + `; +} + +const diagnostic = { + code: "@azure-tools/typespec-azure-resource-manager/no-uuid", +}; + +it("reports a UUID-typed model property", async () => { + await tester + .expect( + inArmService(` + model WidgetProperties { + id: Azure.Core.uuid; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("reports a UUID-typed query parameter", async () => { + await tester + .expect( + inArmService(` + @route("/widgets") + interface Widgets { + @get read(@query requestId: Azure.Core.uuid): string; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("reports a UUID-typed resource name template parameter", async () => { + await tester + .expect(versionedArmService("Type = Azure.Core.uuid,")) + .toEmitDiagnostics([diagnostic, diagnostic, diagnostic, diagnostic]); +}); + +it("reports a UUID-formatted resource name template parameter", async () => { + await tester + .expect( + `${versionedArmService("")} + @@format(Widget.name, "uuid"); + `, + ) + .toEmitDiagnostics([diagnostic, diagnostic, diagnostic, diagnostic]); +}); + +it("reports a direct UUID request body", async () => { + await tester + .expect( + inArmService(` + @route("/widgets") + interface Widgets { + @post create(@body body: Azure.Core.uuid): string; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("reports a direct UUID response body", async () => { + await tester + .expect( + inArmService(` + @route("/widgets") + interface Widgets { + @get read(): Azure.Core.uuid; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("reports a custom scalar derived from UUID", async () => { + await tester + .expect( + inArmService(` + scalar WidgetId extends Azure.Core.uuid; + + model WidgetProperties { + id: WidgetId; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("reports a property-level UUID format", async () => { + await tester + .expect( + inArmService(` + model IdentifierProperties { + id: string; + } + + model WidgetProperties { + ...IdentifierProperties; + } + + @@format(WidgetProperties.id, "uuid"); + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("reports an array property containing UUID values", async () => { + await tester + .expect( + inArmService(` + model WidgetProperties { + relatedIds: Azure.Core.uuid[]; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("allows non-UUID shapes and UUIDs in client-only namespaces", async () => { + await tester + .expect( + ` + ${inArmService(` + model WidgetProperties { + id: string; + relatedIds: string[]; + } + `)} + + namespace Azure.ResourceManager.Test.Models { + model ClientOnlyModel { + id: Azure.Core.uuid; + } + } + `, + ) + .toBeValid(); +}); + +it("reports a UUID property on an unreferenced named model", async () => { + await tester + .expect( + inArmService(` + model UnreferencedModel { + id: Azure.Core.uuid; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("reports UUID values nested in records, tuples, and unions", async () => { + await tester + .expect( + inArmService(` + model WidgetProperties { + recordIds: Record; + tupleIds: [Azure.Core.uuid]; + unionId: Azure.Core.uuid | string; + } + `), + ) + .toEmitDiagnostics([diagnostic, diagnostic, diagnostic]); +}); + +it("reports a UUID-typed response header", async () => { + await tester + .expect( + inArmService(` + @route("/widgets") + interface Widgets { + @get read(): { + @header requestId: Azure.Core.uuid; + @body body: string; + }; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + +it("deduplicates a property reached through declarations and HTTP payloads", async () => { + await tester + .expect( + inArmService(` + model Widget { + id: Azure.Core.uuid; + } + + @route("/widgets") + interface Widgets { + @get read(): Widget; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); diff --git a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts index 1167301d2f..f3f4fe9cf9 100644 --- a/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts +++ b/packages/typespec-azure-rulesets/src/rulesets/resource-manager.ts @@ -95,6 +95,7 @@ export default { "@azure-tools/typespec-azure-resource-manager/lro-location-header": true, "@azure-tools/typespec-azure-resource-manager/missing-x-ms-identifiers": true, "@azure-tools/typespec-azure-resource-manager/no-response-body": true, + "@azure-tools/typespec-azure-resource-manager/no-uuid": false, "@azure-tools/typespec-azure-resource-manager/missing-operations-endpoint": true, "@azure-tools/typespec-azure-resource-manager/patch-envelope": true, "@azure-tools/typespec-azure-resource-manager/arm-resource-patch": 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..09e6c7d30c 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 @@ -57,6 +57,7 @@ Available ruleSets: | [`@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/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/no-uuid`](../rules/no-uuid.md) | ARM APIs should avoid UUID-typed schemas unless they have explicit Azure API review approval. | | [`@azure-tools/typespec-azure-resource-manager/missing-operations-endpoint`](../rules/missing-operations-endpoint.md) | Check for missing Operations interface. | | [`@azure-tools/typespec-azure-resource-manager/patch-envelope`](../rules/patch-envelope.md) | Patch envelope properties should match the resource properties. | | [`@azure-tools/typespec-azure-resource-manager/arm-resource-patch`](../rules/arm-resource-patch.md) | Validate ARM PATCH operations. | From 45aeaffde6c365f672406a11b1d6d55140035640 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Mon, 31 Aug 2026 15:57:55 +0800 Subject: [PATCH 2/6] docs(arm): remove generated rule metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cf125def-85b2-462d-8902-7ac7beb588a3 --- .../typespec-azure-resource-manager/src/rules/no-uuid.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/typespec-azure-resource-manager/src/rules/no-uuid.md b/packages/typespec-azure-resource-manager/src/rules/no-uuid.md index b2911cdcf5..cb40046b8f 100644 --- a/packages/typespec-azure-resource-manager/src/rules/no-uuid.md +++ b/packages/typespec-azure-resource-manager/src/rules/no-uuid.md @@ -1,11 +1,3 @@ ---- -title: "no-uuid" ---- - -```text title="Full name" -@azure-tools/typespec-azure-resource-manager/no-uuid -``` - Avoid UUID-typed schemas in Azure Resource Manager APIs unless their use has explicit Azure API review approval. ## Impact From 674ddf4a29989f5af648d88bf56cbd7828c19c6a Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Wed, 2 Sep 2026 12:47:35 +0800 Subject: [PATCH 3/6] fix(arm): identify Azure Core UUID scalar Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/rules/no-uuid.ts | 42 ++++++++++++++++--- .../test/rules/no-uuid.test.ts | 15 +++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts index f2d38d5231..4109e60610 100644 --- a/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts +++ b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts @@ -6,6 +6,7 @@ import { type Operation, type Program, type RecordModelType, + type Scalar, type Type, createRule, fileRef, @@ -34,6 +35,8 @@ export const noUuidRule = createRule({ }, create(context) { const reportedTargets = new Set(); + const [uuidType] = context.program.resolveTypeReference("Azure.Core.uuid"); + const uuidScalar = uuidType?.kind === "Scalar" ? uuidType : undefined; const [services] = getAllHttpServices(context.program); const armServices = services.filter((service) => getArmProviderNamespace(context.program, service.namespace), @@ -56,6 +59,7 @@ export const noUuidRule = createRule({ if (target !== undefined) { reportUuidUsage( context, + uuidScalar, property.type, target, reportedTargets, @@ -78,7 +82,13 @@ export const noUuidRule = createRule({ if (getFormat(context.program, parameter.param) === "uuid") { reportTarget(context, target, reportedTargets); } else { - reportUuidUsage(context, parameter.param.type, target, reportedTargets); + reportUuidUsage( + context, + uuidScalar, + parameter.param.type, + target, + reportedTargets, + ); } } } @@ -87,6 +97,7 @@ export const noUuidRule = createRule({ if (requestBody !== undefined) { reportUuidUsage( context, + uuidScalar, requestBody.type, getPayloadTarget(context.program, requestBody.property, operation), reportedTargets, @@ -98,6 +109,7 @@ export const noUuidRule = createRule({ if (content.body !== undefined) { reportUuidUsage( context, + uuidScalar, content.body.type, getPayloadTarget(context.program, content.body.property, operation), reportedTargets, @@ -107,7 +119,7 @@ export const noUuidRule = createRule({ for (const header of Object.values(content.headers ?? {})) { const target = getProjectProperty(context.program, header); if (target !== undefined) { - reportUuidUsage(context, header.type, target, reportedTargets); + reportUuidUsage(context, uuidScalar, header.type, target, reportedTargets); } } } @@ -151,6 +163,7 @@ function isWithinNamespace(namespace: Namespace, ancestor: Namespace): boolean { function reportUuidUsage( context: Parameters[0], + uuidScalar: Scalar | undefined, type: Type, target: ModelProperty | Operation, reportedTargets: Set, @@ -176,18 +189,27 @@ function reportUuidUsage( switch (type.kind) { case "Scalar": - if (getFormat(context.program, type) === "uuid") { + if (type === uuidScalar || getFormat(context.program, type) === "uuid") { if (canReportTarget && isProjectDeclaration(context.program, target)) { reportTarget(context, target, reportedTargets); } } else if (type.baseScalar !== undefined) { - reportUuidUsage(context, type.baseScalar, target, reportedTargets, seen, canReportTarget); + reportUuidUsage( + context, + uuidScalar, + type.baseScalar, + target, + reportedTargets, + seen, + canReportTarget, + ); } return; case "Model": if (isContainerModel(type)) { reportUuidUsage( context, + uuidScalar, type.indexer.value, target, reportedTargets, @@ -200,6 +222,7 @@ function reportUuidUsage( const propertyTarget = getProjectProperty(context.program, property); reportUuidUsage( context, + uuidScalar, property.type, propertyTarget ?? target, reportedTargets, @@ -211,13 +234,22 @@ function reportUuidUsage( return; case "Tuple": for (const value of type.values) { - reportUuidUsage(context, value, target, reportedTargets, new Set(seen), canReportTarget); + reportUuidUsage( + context, + uuidScalar, + value, + target, + reportedTargets, + new Set(seen), + canReportTarget, + ); } return; case "Union": for (const variant of type.variants.values()) { reportUuidUsage( context, + uuidScalar, variant.type, target, reportedTargets, diff --git a/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts b/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts index 73648fdb13..94227a670c 100644 --- a/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts +++ b/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts @@ -155,6 +155,21 @@ it("reports a custom scalar derived from UUID", async () => { .toEmitDiagnostics(diagnostic); }); +it("reports a custom scalar with an explicit UUID format", async () => { + await tester + .expect( + inArmService(` + @format("uuid") + scalar WidgetId extends string; + + model WidgetProperties { + id: WidgetId; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + it("reports a property-level UUID format", async () => { await tester .expect( From 44bab683a1f1503e05de16e8be386056b02c22d8 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Thu, 3 Sep 2026 10:44:27 +0800 Subject: [PATCH 4/6] fix(arm): resolve UUID with typekit Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- packages/typespec-azure-resource-manager/src/rules/no-uuid.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts index 4109e60610..aba61cbbfb 100644 --- a/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts +++ b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts @@ -17,6 +17,7 @@ import { isRecordModelType, walkPropertiesInherited, } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; import { getAllHttpServices } from "@typespec/http"; import { getArmProviderNamespace } from "../namespace.js"; @@ -35,8 +36,7 @@ export const noUuidRule = createRule({ }, create(context) { const reportedTargets = new Set(); - const [uuidType] = context.program.resolveTypeReference("Azure.Core.uuid"); - const uuidScalar = uuidType?.kind === "Scalar" ? uuidType : undefined; + const uuidScalar = $(context.program).type.resolve("Azure.Core.uuid", "Scalar"); const [services] = getAllHttpServices(context.program); const armServices = services.filter((service) => getArmProviderNamespace(context.program, service.namespace), From afd2b72bb5d534b86770e3b826facd7b5e966b59 Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Fri, 4 Sep 2026 15:40:37 +0800 Subject: [PATCH 5/6] refactor(arm): use semantic UUID listeners Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/rules/no-uuid.ts | 246 +++++------------- .../test/rules/no-uuid.test.ts | 13 + 2 files changed, 75 insertions(+), 184 deletions(-) diff --git a/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts index aba61cbbfb..1452e8cd79 100644 --- a/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts +++ b/packages/typespec-azure-resource-manager/src/rules/no-uuid.ts @@ -12,10 +12,8 @@ import { fileRef, getFormat, getLocationContext, - getSourceLocation, isArrayModelType, isRecordModelType, - walkPropertiesInherited, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import { getAllHttpServices } from "@typespec/http"; @@ -45,84 +43,44 @@ export const noUuidRule = createRule({ return { modelProperty: (property) => { - const model = property.model; - const modelNamespace = model?.namespace; - if ( - !model?.name || - modelNamespace === undefined || - !armServices.some((service) => isWithinNamespace(modelNamespace, service.namespace)) - ) { + if (!isInArmService(property.model?.namespace, armServices)) { return; } - const target = getProjectProperty(context.program, property); - if (target !== undefined) { - reportUuidUsage( - context, - uuidScalar, - property.type, - target, - reportedTargets, - new Set(), - true, - property, - ); + if ( + getFormat(context.program, property) === "uuid" || + containsUuid(context.program, uuidScalar, property.type) + ) { + reportTarget(context, property, reportedTargets); + } + }, + operation: (operation) => { + const namespace = operation.interface?.namespace ?? operation.namespace; + if ( + isInArmService(namespace, armServices) && + containsUuid(context.program, uuidScalar, operation.returnType) + ) { + reportTarget(context, operation, reportedTargets); } }, root: () => { for (const service of armServices) { for (const httpOperation of service.operations) { const operation = httpOperation.operation; - for (const parameter of httpOperation.parameters.parameters) { - const target = - parameter.param.name === resourceKeyByOperation.get(operation) - ? operation - : getProjectProperty(context.program, parameter.param); - if (target !== undefined) { - if (getFormat(context.program, parameter.param) === "uuid") { - reportTarget(context, target, reportedTargets); - } else { - reportUuidUsage( - context, - uuidScalar, - parameter.param.type, - target, - reportedTargets, - ); - } - } + const resourceKey = resourceKeyByOperation.get(operation); + if (resourceKey === undefined) { + continue; } - const requestBody = httpOperation.parameters.body; - if (requestBody !== undefined) { - reportUuidUsage( - context, - uuidScalar, - requestBody.type, - getPayloadTarget(context.program, requestBody.property, operation), - reportedTargets, - ); - } - - for (const response of httpOperation.responses) { - for (const content of response.responses) { - if (content.body !== undefined) { - reportUuidUsage( - context, - uuidScalar, - content.body.type, - getPayloadTarget(context.program, content.body.property, operation), - reportedTargets, - ); - } - - for (const header of Object.values(content.headers ?? {})) { - const target = getProjectProperty(context.program, header); - if (target !== undefined) { - reportUuidUsage(context, uuidScalar, header.type, target, reportedTargets); - } - } - } + const parameter = httpOperation.parameters.parameters.find( + (parameter) => parameter.param.name === resourceKey, + ); + if ( + parameter !== undefined && + (getFormat(context.program, parameter.param) === "uuid" || + containsUuid(context.program, uuidScalar, parameter.param.type)) + ) { + reportTarget(context, operation, reportedTargets); } } } @@ -161,105 +119,58 @@ function isWithinNamespace(namespace: Namespace, ancestor: Namespace): boolean { return false; } -function reportUuidUsage( - context: Parameters[0], +function isInArmService( + namespace: Namespace | undefined, + services: readonly { namespace: Namespace }[], +): boolean { + return ( + namespace !== undefined && + services.some((service) => isWithinNamespace(namespace, service.namespace)) + ); +} + +function containsUuid( + program: Program, uuidScalar: Scalar | undefined, type: Type, - target: ModelProperty | Operation, - reportedTargets: Set, seen = new Set(), - canReportTarget = true, - formatSource?: ModelProperty, -): void { - const formattedProperty = formatSource ?? (target.kind === "ModelProperty" ? target : undefined); - if ( - formattedProperty !== undefined && - getFormat(context.program, formattedProperty) === "uuid" && - canReportTarget && - isProjectDeclaration(context.program, target) - ) { - reportTarget(context, target, reportedTargets); - } - +): boolean { if (seen.has(type)) { - return; + return false; } seen.add(type); switch (type.kind) { case "Scalar": - if (type === uuidScalar || getFormat(context.program, type) === "uuid") { - if (canReportTarget && isProjectDeclaration(context.program, target)) { - reportTarget(context, target, reportedTargets); - } - } else if (type.baseScalar !== undefined) { - reportUuidUsage( - context, - uuidScalar, - type.baseScalar, - target, - reportedTargets, - seen, - canReportTarget, - ); - } - return; + return ( + type === uuidScalar || + getFormat(program, type) === "uuid" || + (type.baseScalar !== undefined && containsUuid(program, uuidScalar, type.baseScalar, seen)) + ); case "Model": if (isContainerModel(type)) { - reportUuidUsage( - context, - uuidScalar, - type.indexer.value, - target, - reportedTargets, - seen, - canReportTarget, - ); - return; + return containsUuid(program, uuidScalar, type.indexer.value, seen); } - for (const property of walkPropertiesInherited(type)) { - const propertyTarget = getProjectProperty(context.program, property); - reportUuidUsage( - context, - uuidScalar, - property.type, - propertyTarget ?? target, - reportedTargets, - new Set(seen), - propertyTarget !== undefined, - property, - ); + if (getLocationContext(program, type).type === "project") { + return false; } - return; + // Project model properties are visited by the linter. Recurse only through library wrappers + // such as ArmResponse, whose instantiated payload property cannot be reported directly. + return [...type.properties.values()].some( + (property) => + getLocationContext(program, property).type !== "project" && + (getFormat(program, property) === "uuid" || + containsUuid(program, uuidScalar, property.type, new Set(seen))), + ); case "Tuple": - for (const value of type.values) { - reportUuidUsage( - context, - uuidScalar, - value, - target, - reportedTargets, - new Set(seen), - canReportTarget, - ); - } - return; + return type.values.some((value) => containsUuid(program, uuidScalar, value, new Set(seen))); case "Union": - for (const variant of type.variants.values()) { - reportUuidUsage( - context, - uuidScalar, - variant.type, - target, - reportedTargets, - new Set(seen), - canReportTarget, - ); - } - return; + return [...type.variants.values()].some((variant) => + containsUuid(program, uuidScalar, variant.type, new Set(seen)), + ); default: - return; + return false; } } @@ -277,36 +188,3 @@ function reportTarget( function isContainerModel(model: Model): model is ArrayModelType | RecordModelType { return isArrayModelType(model) || isRecordModelType(model); } - -function getPayloadTarget( - program: Program, - property: ModelProperty | undefined, - operation: Operation, -): ModelProperty | Operation { - return property === undefined ? operation : (getProjectProperty(program, property) ?? operation); -} - -function getProjectProperty(program: Program, property: ModelProperty): ModelProperty | undefined { - let source = property; - while (source.sourceProperty !== undefined) { - source = source.sourceProperty; - } - return isProjectDeclaration(program, source) ? source : undefined; -} - -function isProjectDeclaration( - program: Program, - declaration: Model | ModelProperty | Operation, -): boolean { - if (getLocationContext(program, declaration).type === "project") { - return true; - } - - if (declaration.node === undefined) { - return false; - } - - const path = getSourceLocation(declaration.node).file.path.replaceAll("\\", "/"); - const projectRoot = program.projectRoot.replaceAll("\\", "/").replace(/\/$/, ""); - return path === projectRoot || path.startsWith(`${projectRoot}/`); -} diff --git a/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts b/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts index 94227a670c..e8c07ed46a 100644 --- a/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts +++ b/packages/typespec-azure-resource-manager/test/rules/no-uuid.test.ts @@ -141,6 +141,19 @@ it("reports a direct UUID response body", async () => { .toEmitDiagnostics(diagnostic); }); +it("reports a UUID response body through an ARM response template", async () => { + await tester + .expect( + inArmService(` + @route("/widgets") + interface Widgets { + @get read(): ArmResponse; + } + `), + ) + .toEmitDiagnostics(diagnostic); +}); + it("reports a custom scalar derived from UUID", async () => { await tester .expect( From 5dded4ae52306f000067a91e2b62370655f952ed Mon Sep 17 00:00:00 2001 From: Yuchao Yan Date: Tue, 8 Sep 2026 10:09:41 +0800 Subject: [PATCH 6/6] Fix promotion changelog classification Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .chronus/changes/promote-guid-usage-2026-08-28.md | 3 +-- .chronus/changes/register-no-uuid-ruleset.md | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .chronus/changes/register-no-uuid-ruleset.md diff --git a/.chronus/changes/promote-guid-usage-2026-08-28.md b/.chronus/changes/promote-guid-usage-2026-08-28.md index 9930a672e7..29f1e215a9 100644 --- a/.chronus/changes/promote-guid-usage-2026-08-28.md +++ b/.chronus/changes/promote-guid-usage-2026-08-28.md @@ -2,7 +2,6 @@ changeKind: feature packages: - "@azure-tools/typespec-azure-resource-manager" - - "@azure-tools/typespec-azure-rulesets" --- -Add the `no-uuid` ARM lint rule, migrated from the Swagger `GuidUsage` validator rule and disabled by default in the resource-manager ruleset. +Add the `no-uuid` ARM lint rule, migrated from the Swagger `GuidUsage` validator rule. diff --git a/.chronus/changes/register-no-uuid-ruleset.md b/.chronus/changes/register-no-uuid-ruleset.md new file mode 100644 index 0000000000..5fcda6743c --- /dev/null +++ b/.chronus/changes/register-no-uuid-ruleset.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@azure-tools/typespec-azure-rulesets" +--- + +Register the ARM `no-uuid` lint rule as disabled in the resource manager ruleset.