Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .chronus/changes/promote-guid-usage-2026-08-28.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-azure-resource-manager"
---

Add the `no-uuid` ARM lint rule, migrated from the Swagger `GuidUsage` validator rule.
7 changes: 7 additions & 0 deletions .chronus/changes/register-no-uuid-ruleset.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/typespec-azure-resource-manager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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. |
Comment thread
msyyc marked this conversation as resolved.
| [`@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. |
Expand Down
2 changes: 2 additions & 0 deletions packages/typespec-azure-resource-manager/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { noQueryInPointOpRule } from "./rules/no-query-in-point-op.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";
Expand Down Expand Up @@ -85,6 +86,7 @@ const rules = [
lroLocationHeaderRule,
missingXmsIdentifiersRule,
noResponseBodyRule,
noUuidRule,
operationsInterfaceMissingRule,
patchEnvelopePropertiesRules,
patchOperationsRule,
Expand Down
37 changes: 37 additions & 0 deletions packages/typespec-azure-resource-manager/src/rules/no-uuid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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).
190 changes: 190 additions & 0 deletions packages/typespec-azure-resource-manager/src/rules/no-uuid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import {
type ArrayModelType,
type Model,
type ModelProperty,
type Namespace,
type Operation,
type Program,
type RecordModelType,
type Scalar,
type Type,
createRule,
fileRef,
getFormat,
getLocationContext,
isArrayModelType,
isRecordModelType,
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
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<ModelProperty | Operation>();
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),
);
const resourceKeyByOperation = getResourceKeyByOperation(context.program);

return {
modelProperty: (property) => {
Comment thread
msyyc marked this conversation as resolved.
if (!isInArmService(property.model?.namespace, armServices)) {
return;
}

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: () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why do you need to make your own traverse here? can't you just use the linter engine, this seems like this should be a pretty simple rule

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The extra traversal is intentional because this rule is trying to cover the resolved HTTP projection of the API while reporting on actionable authored TypeSpec targets. The regular linter traversal handles ordinary declarations, but it does not fully cover generated ARM HTTP shapes with a useful diagnostic location.

For example:

model Widget is TrackedResource<WidgetProperties> {
  ...ResourceNameParameter<
    Resource = Widget,
    KeyName = "widgetName",
    SegmentName = "widgets",
    Type = Azure.Core.uuid
  >;
}

@armResourceOperations
interface Widgets {
  get is ArmResourceRead<Widget>;
}

This emits an HTTP path parameter with format: uuid, which the Swagger GuidUsage rule reports. The corresponding widgetName model property is generated from an ARM library template and is library-owned, so reporting from a basic modelProperty listener would point into code the service author cannot meaningfully fix or suppress. The HTTP/resource traversal recognizes it as the resource key and maps the finding back to the authored Widgets.get operation.

The same projection traversal covers direct ArmResponse<uuid> bodies and formats applied to generated HTTP parameters. It also lets us exclude imported library-owned findings and deduplicate repeated emitted occurrences. These cases came from the lintdiff corpus investigation and have focused tests in this PR.

Some of the recursive type inspection may still be refactorable, but replacing the resolved HTTP traversal with only semantic listeners would narrow the migrated rule's established coverage or change its diagnostic targets.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

hhm really don't like that by that reasoning every single rule should do their own traversal due to the same limitation. I think if this is critical and can't be hacked in the diagnostic target resolution function right now we need to figure out a better way built in for this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Can't find a better solution. Let us hold on this PR for now and see if other PRs have similar request for traversal or we could find other solution. CC catalinaperalta

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can you not just check modelProperty type(and any other types you want to check) and report on the model property? The typespec engine should report template instantiation trace if it happens in a template

@msyyc Yuchao Yan (msyyc) Sep 4, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Timothee Guerin (@timotheeguerin) Yes, this works for the ordinary cases, and I refactored the rule in afd2b72 to use modelProperty and operation listeners for authored properties, parameters, headers, direct return types, containers, unions, and custom scalars. The resolved HTTP traversal for ordinary parameters, request/response bodies, and headers has been removed.

There is one ARM-specific exception. An instantiated ResourceNameParameter.name property does carry a template mapper, so the compiler can produce an instantiation trace. However, its primary source location remains the library declaration in Azure.ResourceManager.ResourceNameParameter. The linter filters library-targeted diagnostics before presenting related instantiation locations, so reporting that generated property directly produces no user-visible diagnostic.

This is not just theoretical in the migration corpus: there are 9 UUID resource-name declarations across 6 projects (8 across 5 successfully compiled projects; Quota was excluded by its compile failure), producing 35 Swagger operation-parameter findings. Dropping the case would therefore create a known gap in 5 of the 46 projects detected by the final TypeSpec corpus run. (detailed report is here: https://github.com/Azure/typespec-azure/blob/feature/lintdiff-migration-new/packages/typespec-lintdiff/test/fixtures/GuidUsage/migration.md)

As a compromise, the only remaining HTTP projection logic now finds resolved ARM resource-key parameters and maps those library-owned generated parameters to their authored operations. Everything else uses the standard linter traversal. I also added coverage for ArmResponse<Azure.Core.uuid> to ensure a UUID hidden behind a library response wrapper remains detected without recursively reporting project response models. The implementation is reduced by 184 lines, and all 16 focused tests still pass.

for (const service of armServices) {
for (const httpOperation of service.operations) {
const operation = httpOperation.operation;
const resourceKey = resourceKeyByOperation.get(operation);
if (resourceKey === undefined) {
continue;
}

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);
}
}
}
},
};
},
});

function getResourceKeyByOperation(program: Program): Map<Operation, string> {
const result = new Map<Operation, string>();
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 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,
seen = new Set<Type>(),
): boolean {
if (seen.has(type)) {
return false;
}

seen.add(type);

switch (type.kind) {
case "Scalar":
return (
type === uuidScalar ||
getFormat(program, type) === "uuid" ||
(type.baseScalar !== undefined && containsUuid(program, uuidScalar, type.baseScalar, seen))
);
case "Model":
if (isContainerModel(type)) {
return containsUuid(program, uuidScalar, type.indexer.value, seen);
}
if (getLocationContext(program, type).type === "project") {
return false;
}
// Project model properties are visited by the linter. Recurse only through library wrappers
// such as ArmResponse<T>, 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":
return type.values.some((value) => containsUuid(program, uuidScalar, value, new Set(seen)));
case "Union":
return [...type.variants.values()].some((variant) =>
containsUuid(program, uuidScalar, variant.type, new Set(seen)),
);
default:
return false;
}
}

function reportTarget(
context: Parameters<typeof noUuidRule.create>[0],
target: ModelProperty | Operation,
reportedTargets: Set<ModelProperty | Operation>,
): void {
if (!reportedTargets.has(target)) {
reportedTargets.add(target);
context.reportDiagnostic({ target });
}
}

function isContainerModel(model: Model): model is ArrayModelType | RecordModelType {
return isArrayModelType(model) || isRecordModelType(model);
}
Loading
Loading