From 34d9c85a1179923a1b1aad07222ef220770fc9b4 Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Thu, 3 Sep 2026 18:07:09 -0700 Subject: [PATCH 1/7] docs(arm): add resolveArmResources versioned view RFC Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1e98b2e-c932-4db7-9575-469e807e9209 --- .../resolve-arm-resources-versioned-view.md | 971 ++++++++++++++++++ 1 file changed, 971 insertions(+) create mode 100644 packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md diff --git a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md new file mode 100644 index 0000000000..639d2a8ab1 --- /dev/null +++ b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md @@ -0,0 +1,971 @@ +# Versioned and customizable views for `resolveArmResources` + +## Status + +Proposed design for `@azure-tools/typespec-azure-resource-manager`. + +## Summary + +`resolveArmResources(program)` currently returns a provider-wide view of ARM resources and +operations discovered from decorator metadata in the compiled `Program`. That behavior is useful +to consumers that need the complete, multi-version declaration graph, and it must remain the +default. + +This design adds an opt-in selected-version view and an opt-in metadata naming hook: + +- A call without options preserves the current multi-version behavior and cache semantics. +- A call with `version` resolves the requested service snapshot using + `@typespec/versioning`, then resolves resources only from types in that snapshot. +- A dependency-neutral callback can replace resource names, operation names, and operation-group + names after ARM resource identity and operation association are complete. All three are logical + metadata and are not part of the wire API. +- Wire identity is never customizable: provider names, resource type path segments, HTTP paths, + serialized parameter names, and `resourceInstancePath` continue to come from the ARM and HTTP + metadata. + +The implementation should refactor the resolver around an internal resolution context. The +context identifies the namespace root and, for a versioned call, the mutation realm that owns the +selected snapshot. All discovery operations must use that context rather than reading an +unfiltered, program-wide state map. + +The implementation must cache each projected snapshot by program, provider namespace, and +version. `getVersioningMutators` creates new mutator objects on each call, while the compiler +mutation cache is keyed by mutator identity. Without an ARM-owned snapshot cache, repeated calls +would create and retain additional realms and repeatedly register decorator state. + +## Goals + +1. Return resources and operations as they exist in one selected API version, including projected + TypeSpec `Model`, `Operation`, `Interface`, property, and HTTP metadata. +2. Preserve the existing `resolveArmResources(program)` multi-version result and avoid changing + existing callers. +3. Prevent original and projected types from being mixed in a selected-version result. +4. Prevent projected types from entering the default result after a selected-version call. +5. Prevent one version, one naming policy, or call ordering from contaminating another result. +6. Allow TCGC-aware consumers to supply client-facing logical names without introducing an ARM to + TCGC package dependency. +7. Clearly distinguish logical names from ARM wire identity. +8. Provide diagnostics for unknown or ambiguous versions. +9. Make the resolver's invariants and maintenance workflow discoverable to AI coding tools. + +## Non-goals + +- Changing the TypeSpec versioning mutation implementation. +- Replacing TCGC naming APIs or reproducing their language-scope rules in the ARM package. +- Making `resolveArmResources` a complete SDK code model. +- Applying client names to HTTP paths, ARM resource type segments, serialized property names, or + request and response schemas. +- Changing how the current multi-version resolver associates operations with resources. +- Solving general multi-service selection in the first change. The resolver retains its current + provider-selection behavior. + +## Terminology: logical names and wire identity + +The following returned fields are logical metadata, not part of the wire API: + +- `ResolvedResource.resourceName`; +- `ArmResourceOperation.name`; and +- `ArmResourceOperation.operationGroup`. + +They are suitable for consumer-specific naming, including TCGC client names. + +Wire identity and wire shape include: + +- `resourceType.provider`; +- `resourceType.types`; +- `resourceInstancePath`; +- HTTP paths and methods; +- serialized parameter and property names; and +- request and response shapes. + +Parent and scope links are structural relationships derived from wire identity. Changing a logical +name must not change those relationships. + +## Current implementation + +### Public result + +The public result is a `Provider` containing: + +- `resources`, each represented by `ResolvedResource`; +- lifecycle, list, action, and associated operations; +- non-resource `providerOperations`; +- TypeSpec references such as `ResolvedResource.type` and + `ArmResourceOperation.operation`; and +- derived ARM identity such as `resourceType`, `resourceInstancePath`, parent, and scope. + +The entry point is `packages/typespec-azure-resource-manager/src/resource.ts`: + +```ts +export function resolveArmResources(program: Program): Provider; +``` + +Its high-level sequence is: + +1. Resolve the provider namespace. +2. Return `armResolvedResources` if a cached result exists. +3. Enumerate entries registered by ARM decorators. +4. Resolve operation and HTTP metadata for each resource model. +5. Derive resource identity, parents, and scopes. +6. Add unassociated provider operations. +7. Cache the result by provider namespace. + +### Decorator metadata + +ARM decorators register metadata while the TypeSpec program is checked. Important state includes: + +| State key | Key type | Purpose | +| --- | --- | --- | +| `armResources` | `Model` | Registered ARM resource details and the resource TypeSpec model | +| `armResourceOperations` | `Model` | Lifecycle, list, and action operation metadata | +| `resourceOperationList` | `Model` | Operation identifiers associated with a resource | +| `armResourceOperationData` | `Operation` | Identifies operations marked as ARM resource operations | +| `armProviderNamespaces` | `Namespace` | ARM provider namespace metadata | +| `armSingletonResources` | `Model` | Singleton resource metadata | +| `resourceBaseType` | `Model` | Resolved ARM resource base kind | +| `armBuiltInResource` | `Model` | Virtual or built-in resource metadata | +| `customAzureResource` | `Model` | Custom resource metadata | + +Derived caches include: + +| State key | Key type | Purpose | +| --- | --- | --- | +| `armResolvedResources` | `Namespace` | Fully resolved `Provider` result | +| `armResourcesCached` | `Model` | Fully resolved legacy `ArmResourceDetails` | + +`registerArmResource` stores the concrete model in `typespecType`. Operation decorators similarly +store concrete `Model` and `Operation` references. These references are correct for the graph in +which the decorators execute, but a program may later contain both original and mutation-realm +entries. + +### Existing versioning interaction + +`@typespec/versioning` exposes `getVersioningMutators(program, namespace)`. For a versioned service +it returns one snapshot mutator for each root API version. Applying a mutator with +`unsafe_mutateSubgraphWithNamespace` creates a `Realm` and a projected namespace graph. + +The version mutator: + +- removes types and members unavailable in the selected version; +- restores names from before `@renamedFrom`; +- restores property and return types from before type changes; and +- restores required or optional state. + +Mutating a graph can cause decorators to execute for realm-owned copies. ARM state may consequently +contain entries for original types and one or more projected copies. The existing +`listArmResources` workaround deduplicates by namespace-qualified type name and keeps the first +entry. That prevents duplicate resources in the default view, but it cannot select the correct +copy for a requested version. + +### Current cache hazards + +The current cache assumes one logical result per provider namespace. A selected-version API adds +two more dimensions: + +- selected API version or mutation realm; and +- consumer-supplied naming policy. + +Caching a customized result in `armResolvedResources` would make output depend on call order. A +program-wide clear would also disturb the existing multi-version view and other emitters using the +same `Program`. + +## Design principles + +### Preserve the default path + +The no-options overload remains the compatibility boundary: + +```ts +resolveArmResources(program); +``` + +It continues to return the current multi-version view and may continue to use the existing +provider cache. Version filtering is never enabled implicitly, including after another emitter +has created versioning mutation realms. + +Legacy resource and operation enumeration must exclude every realm-owned type. Qualified-name +deduplication is not sufficient because a historical snapshot can rename a clone, causing its +qualified name to differ from the original and leak into the default view. + +### Select a graph, not a version label + +After a version is selected, every TypeSpec reference in the result must come from the selected +graph. Filtering only the final `Provider` by a version availability predicate is insufficient +because it would leave: + +- models with properties from the wrong version; +- renamed types and operations with current rather than historical names; +- operation return types from the wrong version; +- HTTP metadata computed from unprojected operations; and +- decorator metadata containing original type references. + +The selected-version path therefore resolves from a projected namespace graph. + +### Filter state at every discovery boundary + +Program state is an index, not proof that an entry belongs to the selected snapshot. Every ARM +state enumeration used by the selected-version path must require that its key belongs to the +selected realm. + +Lookup by an already-selected realm type can continue to use the normal state accessor. Enumeration +must be realm-aware. + +### Separate structural and display metadata + +Although resource, operation, and operation-group names are not wire API, the current resolver uses +the logical resource name as an internal grouping signal in some association paths. Applying +consumer-specific names before association could therefore split one resource into multiple +resources or merge unrelated resources. This is an implementation-ordering concern, not a claim +that the logical name is wire identity. + +Resolution therefore has two phases: + +1. Structural resolution using ARM, HTTP, and TypeSpec names. +2. Optional logical-name transformation on the completed result. + +No naming callback participates in resource identity, path parsing, parent resolution, scope +resolution, operation association, deduplication, or cache keys. + +## Proposed public API + +Add an options overload while retaining the existing signature: + +```ts +export function resolveArmResources(program: Program): Provider; + +export function resolveArmResources( + program: Program, + options: ResolveArmResourcesOptions, +): Provider; +``` + +The initial options shape is: + +```ts +export interface ResolveArmResourcesOptions { + /** + * Exact value of a member in the service's @versioned enum. + * + * When omitted, resolution uses the existing multi-version view. + */ + version?: string; + + /** + * Optional consumer-owned resolver for logical metadata names. + * + * The callback runs after structural ARM resolution. Returning undefined + * preserves the name produced by the ARM resolver. + */ + nameResolver?: ArmMetadataNameResolver; +} +``` + +Use one discriminated callback rather than callbacks tied to TCGC concepts: + +```ts +export type ArmMetadataNameKind = "resource" | "operation" | "operation-group"; + +export interface ArmMetadataNameRequest { + kind: ArmMetadataNameKind; + program: Program; + version?: string; + defaultName: string; + + /** + * TypeSpec declaration that owns the logical name. + * + * - resource: Model + * - operation: Operation + * - operation-group: Interface + */ + type: Model | Operation | Interface; + + /** + * Resource model associated with operation metadata, when available. + */ + resourceType?: Model; + + /** + * ARM identity of the resolved resource occurrence, when available. + * + * These values are context for choosing a logical name and cannot be changed. + */ + resolvedResourceType?: ResourceType; + resourceInstancePath?: string; + + /** + * True when ARM metadata explicitly supplied the logical resource name. + */ + isExplicit?: boolean; +} + +export type ArmMetadataNameResolver = ( + request: ArmMetadataNameRequest, +) => string | undefined; +``` + +Reasons for this shape: + +- It has no TCGC imports or TCGC types. +- A consumer can close over a `TCGCContext`, emitter language scope, or any other naming service. +- One callback gives future name kinds an additive extension path. +- `defaultName` makes fallback behavior explicit. +- `type` is the projected type in a selected-version call. +- The discriminator prevents a resolver from accidentally treating operation groups as models. + +The callback contract should state: + +- return `undefined` to retain `defaultName`; +- return a non-empty string to replace logical output metadata; +- exceptions propagate to the caller; +- callbacks must not mutate TypeSpec types or the supplied result; +- callbacks may be invoked more than once for the same TypeSpec type; and +- invocation order is not part of the API contract. + +If a callback returns an empty string, report an ARM diagnostic targeted at the supplied TypeSpec +type and retain `defaultName`. Empty names should not silently enter the result. + +### TCGC adapter example + +TCGC remains responsible for its naming precedence and language scopes: + +```ts +const tcgcContext = createTCGCContext(program, emitterName); + +const provider = resolveArmResources(program, { + version: selectedVersion, + nameResolver: ({ kind, type }) => { + switch (kind) { + case "resource": + case "operation": + case "operation-group": + return getLibraryName(tcgcContext, type, languageScope); + } + }, +}); +``` + +The actual adapter may choose `getClientNameOverride` instead of `getLibraryName` if it wants only +explicit `@clientName` values and not `@friendlyName` or generated template names. That policy +belongs to the consumer, not the ARM library. + +The consumer is responsible for aligning the TCGC context's selected API version with the +`version` passed to `resolveArmResources`. The ARM package cannot validate TCGC context options +without introducing the dependency this hook is intended to avoid. + +### Names that can change + +The callback can affect these non-wire logical fields: + +- `ResolvedResource.resourceName`; +- `ArmResourceOperation.name`; +- `ArmResourceOperation.operationGroup`; and +- `ArmResourceOperation.resourceName` and `resourceModelName` where they correspond to the renamed + resource metadata. + +The implementation must update all aliases of an operation consistently. A single operation may +appear under lifecycle metadata, actions, lists, associated operations, or provider operations. + +One model can produce several resolved resource occurrences at different paths. Resource naming +requests therefore include `resolvedResourceType` and `resourceInstancePath`; consumers must not +assume that the TypeSpec model alone uniquely identifies a returned resource. + +The resolver does not enforce uniqueness after logical naming. A consumer can intentionally assign +the same logical name to multiple resources or operations. Structural association and +deduplication have already completed, and entries remain distinguishable by their TypeSpec +references and ARM identity. + +### Names that cannot change + +The callback must not affect: + +- `ResolvedResource.resourceType.provider`; +- `ResolvedResource.resourceType.types`; +- `ResolvedResource.resourceInstancePath`; +- `ResolvedResource.providerNamespace`; +- HTTP method, path, parameters, bodies, or responses; +- serialized names; +- singleton keys; +- parent and scope identity; or +- resource matching and operation association. + +These fields represent the ARM wire contract or structural relationships, unlike the customizable +logical name fields. + +### Synthetic parents + +The resolver can create a parent `ResolvedResource` when a path describes a parent for which no +declared resource record exists. A synthetic parent has no authoritative TypeSpec model of its own. + +Track synthetic parents when they are created, for example in an internal +`WeakSet` owned by the resolution context or through an internal-only source +field. Do not call the resource name resolver for a synthetic parent using the child model as a +proxy. Retain the path-derived parent name. + +## Selected-version resolution + +### Version selection + +The selected-version path performs these steps: + +1. Resolve the original provider/service namespace using existing behavior. +2. Call `getVersioningMutators(program, providerNamespace)`. +3. Handle the result: + - `undefined`: the service is not versioned. Resolve the original graph. A supplied `version` + is invalid and should produce a diagnostic. + - `kind: "transient"`: apply the transient mutator. There is no root service version to match, + so a supplied version is invalid. + - `kind: "versioned"`: find exactly one snapshot whose `version.value` equals the requested + string. +4. Apply the selected mutator with `unsafe_mutateSubgraphWithNamespace`. +5. Require the returned type to be a `Namespace`. +6. Require a non-null mutation realm. A versioned snapshot that produces no realm is an internal + consistency error; it must not fall back to legacy enumeration. +7. Cache the projected namespace and realm by program, provider namespace, and version. +8. Create a realm-aware resolution context. +9. Invalidate derived ARM cache entries for that realm. +10. Resolve resources and operations from the projected namespace and realm-owned metadata. +11. Apply optional logical naming. + +Version matching is exact and case-sensitive because API version enum values are wire values. +There is no implicit `latest` value in this API. Consumers that want latest should select it from +the version enum before calling the resolver. + +### Diagnostics + +Add diagnostics for: + +- a version supplied for an unversioned service; +- a version supplied for a transient-only service; +- no snapshot matching the requested version; +- multiple snapshots matching the requested version, treated as an internal consistency failure; + and +- an empty name returned by a custom name resolver. + +The first three diagnostics should include the requested version and available root version values. +The resolver should report the diagnostic through the program and return an empty `Provider`, +matching the existing ability to return an empty provider when no ARM provider namespace exists. + +If callers need a result-plus-diagnostics API in the future, a separate +`resolveArmResourcesWithDiagnostics` function can be added without changing the compatibility +signature. + +### Internal resolution context + +Refactor the existing implementation to use an internal context: + +```ts +interface ArmResourceResolutionContext { + program: Program; + providerNamespace: Namespace; + version?: string; + realm?: unsafe_Realm; + nameResolver?: ArmMetadataNameResolver; + cacheMode: "legacy" | "none"; +} +``` + +The context provides these predicates: + +```ts +function isTypeInResolution( + context: ArmResourceResolutionContext, + type: Type, +): boolean; + +function isContainerInResolution( + context: ArmResourceResolutionContext, + type: Namespace | Interface, +): boolean; +``` + +Semantics: + +- In a versioned realm, a type is eligible only when + `unsafe_Realm.realmForType.get(type) === context.realm`. +- In the legacy path, exclude all types for which `unsafe_Realm.realmForType.has(type)` is true, + then retain qualified-name deduplication as protection against duplicate original registrations. +- Namespace and operation traversal starts at `context.providerNamespace`, never by resolving the + original provider again. + +The exact-realm test is intentional. Testing only `realm.hasType(type)` is insufficient for an +entry copied from another realm, and accepting original types through the realm state fallback +would recreate the mixed-view bug. + +ARM resource models, their operations, and operation interfaces are expected to be cloned into the +selected realm. Assert and test this invariant. If a future compiler optimization leaves one of +these declarations unowned by the realm, fail with an internal diagnostic rather than silently +returning an incomplete provider. + +### Resource enumeration + +Introduce an internal overload or helper: + +```ts +function listArmResourcesForResolution( + context: ArmResourceResolutionContext, +): ArmResourceDetails[]; +``` + +For the legacy context it delegates to `listArmResources(program)` after that helper is strengthened +to exclude realm-owned resource models. + +For a selected-version context it: + +1. Enumerates registered ARM resource details. +2. Keeps entries whose `typespecType` belongs to the selected realm. +3. Requires the model to be reachable from the projected provider namespace. +4. Deduplicates only duplicate registrations of the same realm-owned model identity. + +It must not deduplicate selected-version resources by qualified name across original and realm +types. Qualified-name first-write-wins is specifically the wrong selection mechanism for a +versioned view. + +Reachability protects against realm entries from another service when several service mutators +have run against the same program. + +### Operation enumeration + +Operation lookup begins with each selected resource model. The following references must belong to +the same selected realm: + +- the `resourceOperationList` key; +- each `ArmOperationIdentifier.operation`; +- each `ArmOperationIdentifier.resource`, when defined; +- each `ArmResourceOperationData.operation`; +- operations traversed for `providerOperations`; and +- interfaces containing those operations. + +An operation whose key is realm-owned but whose stored value references an original operation is +stale metadata. Do not substitute the original operation or silently omit it. Report an internal +diagnostic and fail resolution for that selected view. + +`getAllOperations` must take the projected provider namespace explicitly. It must not call +`resolveProviderNamespace(program)` in a selected-version context. + +### HTTP metadata + +Call `getHttpOperation` with the projected `Operation`. This ensures that: + +- an operation removed in the selected version is absent; +- versioned parameter and return types are projected; +- projected route metadata is used; and +- returned `httpOperation.operation` references the projected operation. + +Do not compute HTTP metadata on the original operation and then attach it to a projected resource. + +## State and cache handling + +### Classify state + +State should be treated as one of two classes: + +1. Registration state produced by decorators and keyed by TypeSpec declarations. +2. Derived resolution caches produced by JavaScript helper APIs. + +Registration state should normally be selected by realm, not cleared globally. Derived caches +should be invalidated for the selected graph before resolution. + +### Derived caches to invalidate + +Before resolving a selected version: + +- delete any `armResolvedResources` entry keyed by the projected provider namespace; +- delete `armResourcesCached` entries keyed by models in the selected realm; and +- clear any future derived cache through one central internal helper. + +Add: + +```ts +function clearArmResourceResolutionCaches( + program: Program, + selector: (type: Type) => boolean, +): void; +``` + +Keep the list of derived keys in one place and document every new ARM cache as registration or +derived state. This avoids another partial invalidation path. + +Do not clear the original provider's cached multi-version result. The existing no-options call +must remain stable before and after selected-version calls. + +### Registration state with stale embedded references + +Some decorator or JavaScript API state can be keyed by a projected type while containing an +original type in its value. The selected-version implementation must not trust the key alone. + +Use one of these mechanisms for each affected state shape: + +1. Prefer filtering and validating embedded TypeSpec references at read time. +2. If decorator execution copies stale values before projected types are finalized, add an + internal ARM state-reset mutator that removes the affected realm-owned state entry before the + decorator is reapplied. +3. If neither is possible, reconstruct the small metadata record from the projected declaration + and public decorator accessors. + +The first implementation should use read-time filtering because it is localized to ARM and does +not depend on compiler decorator-finalization order. Add a state-reset mutator only for a state +shape proven by a regression test to retain stale embedded references. + +Never clear registration maps program-wide. Doing so would remove the metadata required by the +legacy multi-version view and other emitters sharing the program. + +### Resolver result caching + +Use this policy: + +| Call shape | Provider cache | +| --- | --- | +| No options | Existing `armResolvedResources` cache | +| Name resolver only | Resolve or clone from the raw legacy result, then transform names; never cache customized output | +| Selected version | Cache the projected snapshot; optionally cache its structural `Provider` by projected namespace | +| Selected version plus name resolver | Reuse the snapshot or structural provider; never cache customized output | + +Add an ARM-owned snapshot cache: + +```ts +interface ArmVersionSnapshot { + providerNamespace: Namespace; + realm: unsafe_Realm; +} + +WeakMap>> +``` + +This cache is required for correctness and memory stability, not only performance. +`getVersioningMutators` creates fresh mutator objects on each call, and the compiler mutation cache +uses mutator object identity. Reusing only a mutator is also insufficient: a later mutation engine +can return cached clones owned by an earlier realm. Reuse the complete projected namespace and +realm as one unit. + +Any versioned structural `Provider` cache must be keyed by the projected provider namespace or +realm identity and must store only the uncustomized provider. + +### Avoid mutating cached results + +The current result contains object references between child resources, parents, and scope +resources. A naming pass must not mutate a cached structural `Provider`. + +Implement a graph-preserving copy: + +1. Allocate a new `ResolvedResource` for every resource. +2. Copy operation records that contain customizable fields. +3. Reconnect `parent` and resource-valued `scope` through an old-to-new resource map. +4. Preserve TypeSpec and immutable HTTP metadata references. +5. Apply names to the copied graph. + +Alternatively, build an uncached result whenever `nameResolver` is supplied. The graph-preserving +copy is preferred because it avoids repeating HTTP and resource association work for the legacy +view. + +## Naming transformation details + +### Resource names + +For each declared `ResolvedResource`, invoke: + +```ts +nameResolver({ + kind: "resource", + program, + version, + defaultName: resource.resourceName, + type: resource.type, + resolvedResourceType: resource.resourceType, + resourceInstancePath: resource.resourceInstancePath, + isExplicit: /* retained from structural resolution */, +}); +``` + +The current private `resourceNameIsExplicit` value should be retained long enough to populate the +request. It need not become a public `ResolvedResource` property unless another consumer needs it. + +A non-empty callback result replaces only `ResolvedResource.resourceName` and corresponding +logical operation metadata. It does not replace resource type segments. + +### Operation names + +For every distinct returned `Operation`, invoke: + +```ts +nameResolver({ + kind: "operation", + program, + version, + defaultName: armOperation.name, + type: armOperation.operation, + resourceType, +}); +``` + +Use an identity map keyed by `Operation` so all appearances of one operation get the same resolved +name and the callback is not repeatedly evaluated for aliases. + +### Operation-group names + +When `operation.interface` is defined, invoke: + +```ts +nameResolver({ + kind: "operation-group", + program, + version, + defaultName: armOperation.operationGroup, + type: armOperation.operation.interface, + resourceType, +}); +``` + +All operations currently returned by this resolver belong to an interface. Keep the interface +guard in the naming pass so the callback remains safe if that prerequisite changes later. + +### Naming precedence + +The ARM package defines only this precedence: + +1. Non-empty custom resolver result. +2. Existing ARM resolver name. + +TCGC-specific precedence remains in TCGC. For example, `getLibraryName` currently incorporates +language-scoped `@clientName`, unscoped `@clientName`, `@friendlyName`, generated template names, +and the TypeSpec declaration name. + +### Explicit logical ARM resource names + +An explicitly supplied logical ARM resource name is included as `defaultName` with +`isExplicit: true`. It is still not wire metadata. The callback is allowed to override it because +the callback is an explicitly requested consumer view. Structural resolution has already +completed, so the override cannot alter resource association. + +## Alternatives considered + +### Filter the current multi-version result + +This can remove resources and operations that are unavailable in a version, but it cannot safely +project renamed declarations, changed property types, changed return types, or decorator metadata. +It also leaves original TypeSpec references in the result. + +Use post-filtering only as a defensive check after projection, not as the primary implementation. + +### Add only `resolveArmResourcesForVersion` + +A separate function is clear and could return diagnostics without overloading the existing shape. +However, version and naming are both view options over the same structural resolver. An options +overload keeps the API cohesive and leaves room for future view settings. + +A separate convenience function can still be added later: + +```ts +resolveArmResourcesForVersion(program, version, options); +``` + +It should delegate to the options overload rather than implement another path. + +### Require consumers to mutate first + +This avoids an ARM dependency on `@typespec/versioning`, but it exposes mutation ordering, realm +selection, cache invalidation, and state filtering to every consumer. Callers can easily pass the +original program and receive a mixed result. The ARM library already has a peer dependency on +`@typespec/versioning`, so it should own the invariant. + +### Cache only the resolved provider by version string + +A `(provider namespace, version string)` cache of a customized `Provider` does not account for +naming callback identity and is unsafe. A cache of the complete projection snapshot by +`(Program, provider namespace, version string)` is required. A structural provider may then be +cached by its projected namespace or realm identity. + +### Import TCGC and call `getLibraryName` + +This would create an undesirable dependency direction and force all ARM consumers to adopt TCGC +context, emitter scope, and naming policy. It could also create circular dependencies. The callback +keeps ownership with the consumer. + +### Generic post-processing callback over `Provider` + +Giving consumers an arbitrary mutable `Provider` callback is flexible but unsafe. It can change +wire identity, break parent references, or mutate cached output. A constrained name resolver +expresses the supported customization and preserves invariants. + +## Implementation plan + +### Phase 1: Refactor without behavior changes + +1. Introduce `ArmResourceResolutionContext`. +2. Move the body of `resolveArmResources` to an internal resolver accepting the context. +3. Make provider-operation traversal accept an explicit namespace. +4. Keep the public no-options function on the legacy context and existing cache. +5. Add characterization tests proving no result changes. + +### Phase 2: Selected-version view + +1. Add options and version-selection diagnostics. +2. Select and apply the `@typespec/versioning` snapshot mutator. +3. Cache the projected namespace and realm as one snapshot. +4. Exclude all realm-owned entries from the default view. +5. Add realm-aware resource and operation enumeration. +6. Centralize derived-cache invalidation. +7. Add selected-version tests for presence, removal, rename, type change, and operation changes. + +### Phase 3: Name resolver + +1. Add public callback types. +2. Preserve structural name origin and explicitness until post-processing. +3. Mark synthetic resources so they are excluded from model-based naming. +4. Add graph-preserving copy and name transformation. +5. Test resource, operation, and operation-group names. +6. Add a TCGC integration test in the TCGC package using `getLibraryName`. +7. Assert that the ARM package manifest and source have no TCGC dependency or import. + +### Phase 4: Documentation and API review + +1. Add API reference comments and a usage example. +2. Regenerate package API documentation if the public types are included in generated docs. +3. Add a change description for the ARM package. +4. Review whether selected-version diagnostics need a result-plus-diagnostics convenience API. + +## Test plan + +### Compatibility tests + +- Existing unversioned fixtures produce deeply equal results before and after the refactor. +- Existing versioned multi-version fixtures still produce the current result with no options. +- Calling the selected-version overload does not change a later no-options result. +- Calling the no-options overload first does not change a later selected-version result. +- Calling TCGC context creation before either overload does not introduce duplicates. + +### Version membership + +Create a service with at least three versions and verify: + +- a resource added in V2 is absent in V1 and present in V2 and V3; +- a resource removed in V3 is present in V1 and V2 and absent in V3; +- an operation added or removed by version follows the same rule; +- provider operations are filtered as well as resource operations; and +- parent and scope records do not reference resources absent from the selected version. + +### Projected type shape + +Verify returned TypeSpec references: + +- a model renamed in V2 has the historical name in V1 and current name in V2; +- a model property changed in V2 has the historical type in V1; +- a property made optional or required changes correctly; +- an operation return type changed in V2 has the historical type in V1; +- a removed property is absent; +- every declared returned model and operation belongs to the selected realm; and +- HTTP operation metadata points at the selected operation. + +### State isolation and cache ordering + +Run permutations in one compiled program: + +1. legacy, V1, V2; +2. V2, V1, legacy; +3. V1 twice; +4. V1 with naming A, V1 with naming B; +5. legacy with naming A, legacy without naming; and +6. create TCGC context, V1, legacy. + +Compare an explicit stable projection of the results: resource type segments, instance paths, +logical names, operation paths and verbs, and parent and scope identities. TypeSpec and HTTP +objects are graph references and are not suitable for unrestricted deep equality. Assert realm +ownership separately, and assert no cross-call name or version contamination. + +### Stale embedded metadata + +Add focused tests in which decorator arguments contain: + +- a resource model; +- an operation; +- an operation return model; and +- a parent resource. + +Verify that selected-version state references projected types. A test that exposes an original +reference should drive either read-time filtering or a targeted pre-finalization state reset. + +### Naming + +Verify: + +- resource names can change; +- operation names can change; +- operation-group names can change; +- explicit ARM resource names are passed with `isExplicit: true`; +- `undefined` preserves defaults; +- empty names report a diagnostic and preserve defaults; +- one operation receives a consistent name everywhere it appears; +- synthetic parent names remain path-derived; +- wire resource type segments and paths never change; and +- two resolvers used sequentially do not mutate each other's results. + +### TCGC integration + +In `typespec-client-generator-core` tests: + +- apply unscoped `@clientName` to a resource model, operation, and operation interface; +- apply language-scoped names and pass the matching scope through the adapter; +- verify `getLibraryName` values appear in logical ARM metadata; +- verify the selected-version callback receives projected types; and +- align the TCGC context and ARM resolver to the same selected version in the test; and +- verify ARM remains absent from TCGC's runtime dependency direction unless the test already uses + the ARM package as a development dependency. + +## Validation commands + +Use the repository's mise-managed tools: + +```powershell +mise exec -- pnpm -r --filter "@azure-tools/typespec-azure-resource-manager..." build +mise exec -- pnpm --filter "@azure-tools/typespec-azure-resource-manager" test +mise exec -- pnpm --filter "@azure-tools/typespec-client-generator-core" test +mise exec -- pnpm format +mise exec -- pnpm lint +``` + +Run the smallest targeted Vitest selectors while iterating, then the package suites above. + +## Compatibility and rollout + +- The change is additive at the TypeScript API level. +- The no-options overload remains the stable behavior. +- Consumers opt into projection and custom naming independently. +- Selected-version results contain different TypeSpec object identities by design because they + reference projected types. +- The API should be marked as using TypeSpec's experimental mutation facility internally, but the + mutator type is not exposed in the public signature. +- If compiler mutation APIs change, only the internal snapshot creation layer should need updates. + +## Open implementation questions + +These questions should be answered by prototype tests rather than assumptions: + +1. Which ARM state records are reapplied with fully projected embedded references, and which need + read-time validation or reset? +2. Can the graph-preserving naming copy share `HttpOperation` objects safely, or do any consumers + mutate them? +3. Should invalid version selection return `{}` after reporting a diagnostic, or should a future + diagnostics-returning convenience API be included in the first release? + +`unsafe_mutateSubgraphWithNamespace` prepares namespace mutation from the program's global +namespace even when a service namespace is supplied as the requested root. TCGC passes the global +namespace so it can compose mutators for multiple services. The initial ARM design still targets +one provider namespace and stores the projected provider returned by the selected snapshot. + +## Decision record + +The recommended direction is: + +1. Keep `resolveArmResources(program)` unchanged as the multi-version view. +2. Add an options overload with exact `version` and dependency-neutral `nameResolver`. +3. Use `@typespec/versioning` mutation for selected versions. +4. Cache each projected namespace and realm by program, provider namespace, and exact version. +5. Resolve through an explicit namespace-and-realm context. +6. Exclude all realm-owned types from the default view. +7. Filter selected registration state by exact realm and validate embedded type references. +8. Invalidate only derived cache entries belonging to the selected graph. +9. Never cache customized `Provider` results. +10. Apply logical naming after structural resolution on a non-cached result copy. +11. Allow resource, operation, and operation-group logical names to change. +12. Keep all ARM wire identity and structural relationships immutable while allowing all three + logical name categories to change. From 493381021ff1e7361a7cc1af4839766ef494260d Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Thu, 3 Sep 2026 18:08:05 -0700 Subject: [PATCH 2/7] docs(arm): define RFC test coverage requirements Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1e98b2e-c932-4db7-9575-469e807e9209 --- .../resolve-arm-resources-versioned-view.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md index 639d2a8ab1..f75501aa8f 100644 --- a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md +++ b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md @@ -911,6 +911,56 @@ In `typespec-client-generator-core` tests: - verify ARM remains absent from TCGC's runtime dependency direction unless the test already uses the ARM package as a development dependency. +### Test coverage requirements + +The implementation is not complete until automated tests cover every new behavior and failure +path introduced by the versioned and customizable views. + +Required coverage: + +- Every branch of version selection: + - no version option; + - matching versioned snapshot; + - unknown version; + - unversioned service with a requested version; + - transient versioning with a requested root version; + - multiple matching snapshots or another internal consistency failure; and + - a snapshot mutation that unexpectedly returns no realm. +- Snapshot cache hit and miss behavior, including proof that two requests for the same service and + version reuse the same projected namespace and realm. +- Legacy enumeration before and after one or more mutations, including a projected type renamed to + a qualified name different from its original declaration. +- Realm filtering for resource models, operations, operation interfaces, provider operations, and + embedded references in decorator-created state. +- Derived cache invalidation and isolation for every cache key touched by the implementation. +- Every naming kind: resource, operation, and operation group. +- Every naming callback outcome: replacement name, `undefined`, empty string, and thrown + exception. +- Consistent renaming of all aliases and logical fields for one operation, including + `resourceName` and `resourceModelName` where applicable. +- Declared resources, synthetic parents, resource-valued scopes, and provider operations. +- Multiple resolved resource occurrences backed by the same TypeSpec model. +- All call-order permutations listed in the state isolation section. +- TCGC integration for unscoped and language-scoped names at the selected API version. + +Coverage gates: + +1. New version-selection, snapshot-cache, realm-filtering, cache-invalidation, graph-copy, and + naming helpers should have 100% branch coverage. +2. If a branch cannot be exercised because it is a defensive compiler invariant, cover the + nearest observable failure path and document the exception in the test with the relevant + compiler invariant. +3. The ARM package's aggregate line, function, statement, and branch coverage must not decrease + from the merge-base report. +4. TCGC integration changes must not decrease the TCGC package's aggregate coverage. +5. Tests must assert behavior, returned TypeSpec object ownership, and cache isolation; executing a + line without checking its result does not satisfy this requirement. +6. Do not exclude the new resolver code from coverage configuration. + +During implementation, compare package coverage against the merge base rather than introducing a +new repository-wide numeric threshold. The package currently runs Vitest coverage without a +package-specific threshold in `package.json`. + ## Validation commands Use the repository's mise-managed tools: @@ -918,7 +968,9 @@ Use the repository's mise-managed tools: ```powershell mise exec -- pnpm -r --filter "@azure-tools/typespec-azure-resource-manager..." build mise exec -- pnpm --filter "@azure-tools/typespec-azure-resource-manager" test +mise exec -- pnpm --filter "@azure-tools/typespec-azure-resource-manager" test:ci mise exec -- pnpm --filter "@azure-tools/typespec-client-generator-core" test +mise exec -- pnpm --filter "@azure-tools/typespec-client-generator-core" test:ci mise exec -- pnpm format mise exec -- pnpm lint ``` From e34b4f341027bc462ec0b31f5f96b0aa29793b4f Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Thu, 3 Sep 2026 18:08:21 -0700 Subject: [PATCH 3/7] docs(arm): add resolveArmResources maintenance skill Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1e98b2e-c932-4db7-9575-469e807e9209 --- .../update-resolve-arm-resources/SKILL.md | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 .github/skills/update-resolve-arm-resources/SKILL.md diff --git a/.github/skills/update-resolve-arm-resources/SKILL.md b/.github/skills/update-resolve-arm-resources/SKILL.md new file mode 100644 index 0000000000..bf64360844 --- /dev/null +++ b/.github/skills/update-resolve-arm-resources/SKILL.md @@ -0,0 +1,276 @@ +--- +name: update-resolve-arm-resources +description: > + Update, debug, or review the typespec-azure-resource-manager resolveArmResources API, + including versioned resource views, operation association, ARM state and cache isolation, + projected TypeSpec types, and dependency-neutral logical naming hooks. +allowed-tools: shell +--- + +# Update `resolveArmResources` + +Use this skill for changes to `resolveArmResources`, `ResolvedResource`, `Provider`, ARM resource +operation resolution, selected-version ARM metadata, or integrations that consume this metadata. + +Read +`packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md` +before changing the API or its state and cache behavior. + +## Purpose of the API + +`resolveArmResources(program)` translates ARM decorators and HTTP metadata into a provider model +containing: + +- declared ARM resources; +- resource lifecycle, list, action, and associated operations; +- non-resource provider operations; +- ARM resource type and instance-path identity; +- parent and scope relationships; +- singleton metadata; and +- references to the TypeSpec models and operations that produced the metadata. + +The no-options API is a multi-version declaration view. A selected-version API must instead return +projected types and only declarations available in the requested API version. + +## Core invariants + +Preserve all of these invariants: + +1. The no-options result remains backward compatible. +2. A selected-version result never mixes original types with types from a mutation realm. +3. Resource association is based on ARM and HTTP structural metadata before client-facing names + are applied. +4. Resource names, operation names, and operation-group names are logical metadata, not wire API. +5. Wire identity never changes through a naming hook. +6. Consumer-specific names never enter a shared provider cache. +7. A call for one version cannot affect another version or the default view. +8. Parent and resource-valued scope references point into the same returned resource graph. +9. Provider operations are selected from the same namespace graph as resources. +10. Decorator state keyed by a projected type is not trusted until embedded type references are + checked. +11. Program-wide ARM registration state is never cleared to prepare one selected version. +12. Repeated resolution of the same version reuses the same projected namespace and realm. + +## Implementation map + +Read these files together before editing: + +| File | Responsibility | +| --- | --- | +| `packages/typespec-azure-resource-manager/src/resource.ts` | Public result types, main resolver, identity parsing, parent and scope resolution, provider operation traversal | +| `packages/typespec-azure-resource-manager/src/private.decorators.ts` | Resource registration and `listArmResources` | +| `packages/typespec-azure-resource-manager/src/operations.ts` | Resource operation registration and operation state | +| `packages/typespec-azure-resource-manager/src/namespace.ts` | Provider namespace registration and lookup | +| `packages/typespec-azure-resource-manager/src/state.ts` | ARM state keys | +| `packages/typespec-azure-resource-manager/test/resource-resolution.test.ts` | Resolver behavior and regression tests | +| `packages/typespec-client-generator-core/src/public-utils.ts` | TCGC `getLibraryName` policy | +| `packages/typespec-client-generator-core/src/decorators.ts` | TCGC `@clientName` lookup | +| `packages/typespec-client-generator-core/src/internal-utils.ts` | Existing version mutation usage | +| `core/packages/versioning/src/mutator.ts` | Snapshot mutator creation | +| `core/packages/compiler/src/experimental/mutators.ts` | Mutation and realm creation | +| `core/packages/compiler/src/experimental/realm.ts` | Realm ownership and state-map behavior | + +## Resolution flow + +The current resolver: + +1. Finds the ARM provider namespace. +2. Checks the resolved-provider cache. +3. Enumerates decorator-registered resource models. +4. Resolves resource operation candidates and HTTP operations. +5. Detects resource identities from strict instance lifecycle paths. +6. Associates non-identity operations. +7. Resolves parent and scope relationships. +8. Collects unassociated provider operations. +9. Caches the provider result. + +When modifying one stage, inspect its downstream assumptions. Resource names are logical metadata, +not wire API, but the current implementation uses them as an internal association signal. +External naming must therefore run after association. + +## Versioning workflow + +For a selected API version: + +1. Resolve the original provider namespace. +2. Use `getVersioningMutators(program, providerNamespace)`. +3. Match the exact root version value. +4. Apply the snapshot with `unsafe_mutateSubgraphWithNamespace`. +5. Require and cache the returned namespace and non-null realm by program, provider namespace, and + version. +6. Resolve from the cached namespace and realm, not by looking up the provider again. +7. Filter enumerated ARM state to exact realm-owned keys. +8. Validate embedded `Model`, `Operation`, `Interface`, and property references. +9. Clear only derived cache entries for the selected graph. +10. Compute HTTP metadata from projected operations. + +`getVersioningMutators` creates new mutator objects each time, and the compiler mutation cache keys +on mutator identity. Never recreate a snapshot on every resolver call, and never cache only the +mutator. Cache the projected namespace and its owning realm together. + +The default view must explicitly exclude all realm-owned types. Qualified-name deduplication is not +enough because historical snapshots can rename a projected type. + +Do not implement a selected-version view by filtering only the final provider. Projection is +required for renames, type changes, optionality changes, removed members, and operation return +types. + +## State and cache classification + +Treat these as registration state: + +- `armResources` +- `armResourceOperations` +- `resourceOperationList` +- `armResourceOperationData` +- `armProviderNamespaces` +- `armSingletonResources` +- `resourceBaseType` +- `armBuiltInResource` +- `customAzureResource` + +Treat these as derived caches: + +- `armResolvedResources` +- `armResourcesCached` + +When adding a state key, document which class it belongs to. Add every new derived cache to the +central invalidation helper. + +Realm state maps can fall back to parent program state for original types. An entry visible from a +realm is not necessarily owned by that realm. Check +`unsafe_Realm.realmForType.get(type) === selectedRealm`. + +## Naming integrations + +The ARM package must not import TCGC. + +Expose or use a dependency-neutral callback that receives: + +- name kind; +- projected TypeSpec declaration; +- current ARM logical name; +- selected version; +- associated resource model when relevant; and +- whether a resource name was explicit ARM metadata. + +Apply naming only after structural resolution. All supported names are non-wire logical metadata: + +- resource name; +- operation name; and +- operation-group name. + +Never change: + +- provider namespace; +- resource type path segments; +- resource instance path; +- HTTP path or method; +- serialized names; +- parent or scope identity; or +- singleton keys. + +Do not describe resource names, operation names, or operation-group names as wire API. Only their +current use as internal resolver grouping data requires the post-resolution ordering. + +Keep `ArmResourceOperation.resourceName` and `resourceModelName` consistent with resource naming. +Include resource type and instance path in resource naming requests because one model can produce +several resolved resource occurrences. + +Track synthetic parents internally and do not invoke model-based naming for them. Current synthetic +parents reuse the child model in their `type` field, so model identity alone cannot distinguish +them. + +For a TCGC integration test, let the consumer call `getLibraryName` or +`getClientNameOverride`. Do not duplicate TCGC precedence in ARM. + +## Required tests + +Start with failing tests for the requested behavior. + +### Compatibility + +- Existing unversioned result is unchanged. +- Existing multi-version result is unchanged when no version is supplied. +- Creating a TCGC context before resolution does not create duplicate resources. + +### Selected versions + +Use at least three versions and cover: + +- resource added and removed; +- operation added and removed; +- model or operation renamed; +- property type changed; +- property made optional or required; +- operation return type changed; +- provider operation filtering; and +- parent and scope consistency. + +Assert returned declared types belong to the selected realm. + +### Isolation + +Run different call orders in one compiled program: + +- default then V1 then V2; +- V2 then V1 then default; +- the same version twice; +- different name resolvers for the same version; and +- named then unnamed resolution. + +Compare stable scalar projections rather than deeply comparing embedded TypeSpec graphs. Assert +realm ownership separately. No custom name may leak to another call. + +### Naming + +- Rename a resource model, operation, and operation interface. +- Verify all aliases of one operation receive the same name. +- Verify paths and resource type segments remain unchanged. +- Verify synthetic parent names are not derived from the child model's client name. +- Verify empty callback results produce the intended diagnostic behavior. + +## Common failure modes + +- Deduplicating projected and original resources by qualified name. This can choose the original + model for a selected version. +- Allowing a renamed realm type into the default view because its qualified name differs. +- Recreating version mutators and realms for every call. +- Caching a mutator without caching the projected namespace and its owning realm. +- Calling `resolveProviderNamespace(program)` after mutation. It can return the original namespace. +- Traversing all program operations instead of the selected provider namespace. +- Reusing `armResolvedResources` for a customized or versioned call. +- Mutating a cached `Provider` during post-processing. +- Checking only a state-map key while a value still references original types. +- Applying TCGC names before resource identity matching. +- Applying the child model's name to a synthetic parent. +- Renaming ARM wire resource type segments with a client name. +- Clearing ARM registration maps program-wide. +- Adding an ARM runtime dependency on TCGC. + +## Editing rules + +- Keep the no-options path explicit and easy to compare with previous behavior. +- Prefer a small internal resolution context over optional parameters threaded independently + through many helpers. +- Use proper TypeScript types; do not use `any` to bypass realm or operation distinctions. +- Preserve existing resource graph references when copying results. +- Add comments only where realm ownership or cache isolation is non-obvious. +- Do not change unrelated resolver heuristics while adding version or naming support. +- If changing `.tsp` files, run `tsp format`. +- If changing decorator option models in `.tsp`, regenerate TypeScript types with `tspd`. + +## Validation + +Use mise from the repository root: + +```powershell +mise exec -- pnpm -r --filter "@azure-tools/typespec-azure-resource-manager..." build +mise exec -- pnpm --filter "@azure-tools/typespec-azure-resource-manager" test +mise exec -- pnpm --filter "@azure-tools/typespec-client-generator-core" test +mise exec -- pnpm format +mise exec -- pnpm lint +``` + +During development, use targeted Vitest selectors first. Before a PR, add the required Chronus +change description for every affected package. From e05d5584a4de637369f956888077faf4c64bd100 Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Tue, 8 Sep 2026 11:29:07 -0700 Subject: [PATCH 4/7] refactor(arm): prepare resolveArmResources resolution context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1e98b2e-c932-4db7-9575-469e807e9209 --- ...m-resources-context-2026-09-03-18-45-00.md | 7 + .../update-resolve-arm-resources/SKILL.md | 30 ++--- .../resolve-arm-resources-versioned-view.md | 57 ++++---- .../src/resource.ts | 40 ++++-- .../test/resource-resolution.test.ts | 123 ++++++++++++++++++ 5 files changed, 196 insertions(+), 61 deletions(-) create mode 100644 .chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md diff --git a/.chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md b/.chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md new file mode 100644 index 0000000000..a19f7581cc --- /dev/null +++ b/.chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@azure-tools/typespec-azure-resource-manager" +--- + +Refactor `resolveArmResources` around an explicit provider resolution context without changing its public behavior. diff --git a/.github/skills/update-resolve-arm-resources/SKILL.md b/.github/skills/update-resolve-arm-resources/SKILL.md index bf64360844..30770e109b 100644 --- a/.github/skills/update-resolve-arm-resources/SKILL.md +++ b/.github/skills/update-resolve-arm-resources/SKILL.md @@ -47,7 +47,7 @@ Preserve all of these invariants: 8. Parent and resource-valued scope references point into the same returned resource graph. 9. Provider operations are selected from the same namespace graph as resources. 10. Decorator state keyed by a projected type is not trusted until embedded type references are - checked. + checked. 11. Program-wide ARM registration state is never cleared to prepare one selected version. 12. Repeated resolution of the same version reuses the same projected namespace and realm. @@ -55,20 +55,20 @@ Preserve all of these invariants: Read these files together before editing: -| File | Responsibility | -| --- | --- | -| `packages/typespec-azure-resource-manager/src/resource.ts` | Public result types, main resolver, identity parsing, parent and scope resolution, provider operation traversal | -| `packages/typespec-azure-resource-manager/src/private.decorators.ts` | Resource registration and `listArmResources` | -| `packages/typespec-azure-resource-manager/src/operations.ts` | Resource operation registration and operation state | -| `packages/typespec-azure-resource-manager/src/namespace.ts` | Provider namespace registration and lookup | -| `packages/typespec-azure-resource-manager/src/state.ts` | ARM state keys | -| `packages/typespec-azure-resource-manager/test/resource-resolution.test.ts` | Resolver behavior and regression tests | -| `packages/typespec-client-generator-core/src/public-utils.ts` | TCGC `getLibraryName` policy | -| `packages/typespec-client-generator-core/src/decorators.ts` | TCGC `@clientName` lookup | -| `packages/typespec-client-generator-core/src/internal-utils.ts` | Existing version mutation usage | -| `core/packages/versioning/src/mutator.ts` | Snapshot mutator creation | -| `core/packages/compiler/src/experimental/mutators.ts` | Mutation and realm creation | -| `core/packages/compiler/src/experimental/realm.ts` | Realm ownership and state-map behavior | +| File | Responsibility | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `packages/typespec-azure-resource-manager/src/resource.ts` | Public result types, main resolver, identity parsing, parent and scope resolution, provider operation traversal | +| `packages/typespec-azure-resource-manager/src/private.decorators.ts` | Resource registration and `listArmResources` | +| `packages/typespec-azure-resource-manager/src/operations.ts` | Resource operation registration and operation state | +| `packages/typespec-azure-resource-manager/src/namespace.ts` | Provider namespace registration and lookup | +| `packages/typespec-azure-resource-manager/src/state.ts` | ARM state keys | +| `packages/typespec-azure-resource-manager/test/resource-resolution.test.ts` | Resolver behavior and regression tests | +| `packages/typespec-client-generator-core/src/public-utils.ts` | TCGC `getLibraryName` policy | +| `packages/typespec-client-generator-core/src/decorators.ts` | TCGC `@clientName` lookup | +| `packages/typespec-client-generator-core/src/internal-utils.ts` | Existing version mutation usage | +| `core/packages/versioning/src/mutator.ts` | Snapshot mutator creation | +| `core/packages/compiler/src/experimental/mutators.ts` | Mutation and realm creation | +| `core/packages/compiler/src/experimental/realm.ts` | Realm ownership and state-map behavior | ## Resolution flow diff --git a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md index f75501aa8f..c04c2bf842 100644 --- a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md +++ b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md @@ -114,24 +114,24 @@ Its high-level sequence is: ARM decorators register metadata while the TypeSpec program is checked. Important state includes: -| State key | Key type | Purpose | -| --- | --- | --- | -| `armResources` | `Model` | Registered ARM resource details and the resource TypeSpec model | -| `armResourceOperations` | `Model` | Lifecycle, list, and action operation metadata | -| `resourceOperationList` | `Model` | Operation identifiers associated with a resource | -| `armResourceOperationData` | `Operation` | Identifies operations marked as ARM resource operations | -| `armProviderNamespaces` | `Namespace` | ARM provider namespace metadata | -| `armSingletonResources` | `Model` | Singleton resource metadata | -| `resourceBaseType` | `Model` | Resolved ARM resource base kind | -| `armBuiltInResource` | `Model` | Virtual or built-in resource metadata | -| `customAzureResource` | `Model` | Custom resource metadata | +| State key | Key type | Purpose | +| -------------------------- | ----------- | --------------------------------------------------------------- | +| `armResources` | `Model` | Registered ARM resource details and the resource TypeSpec model | +| `armResourceOperations` | `Model` | Lifecycle, list, and action operation metadata | +| `resourceOperationList` | `Model` | Operation identifiers associated with a resource | +| `armResourceOperationData` | `Operation` | Identifies operations marked as ARM resource operations | +| `armProviderNamespaces` | `Namespace` | ARM provider namespace metadata | +| `armSingletonResources` | `Model` | Singleton resource metadata | +| `resourceBaseType` | `Model` | Resolved ARM resource base kind | +| `armBuiltInResource` | `Model` | Virtual or built-in resource metadata | +| `customAzureResource` | `Model` | Custom resource metadata | Derived caches include: -| State key | Key type | Purpose | -| --- | --- | --- | -| `armResolvedResources` | `Namespace` | Fully resolved `Provider` result | -| `armResourcesCached` | `Model` | Fully resolved legacy `ArmResourceDetails` | +| State key | Key type | Purpose | +| ---------------------- | ----------- | ------------------------------------------ | +| `armResolvedResources` | `Namespace` | Fully resolved `Provider` result | +| `armResourcesCached` | `Model` | Fully resolved legacy `ArmResourceDetails` | `registerArmResource` stores the concrete model in `typespecType`. Operation decorators similarly store concrete `Model` and `Operation` references. These references are correct for the graph in @@ -299,9 +299,7 @@ export interface ArmMetadataNameRequest { isExplicit?: boolean; } -export type ArmMetadataNameResolver = ( - request: ArmMetadataNameRequest, -) => string | undefined; +export type ArmMetadataNameResolver = (request: ArmMetadataNameRequest) => string | undefined; ``` Reasons for this shape: @@ -468,10 +466,7 @@ interface ArmResourceResolutionContext { The context provides these predicates: ```ts -function isTypeInResolution( - context: ArmResourceResolutionContext, - type: Type, -): boolean; +function isTypeInResolution(context: ArmResourceResolutionContext, type: Type): boolean; function isContainerInResolution( context: ArmResourceResolutionContext, @@ -502,9 +497,7 @@ returning an incomplete provider. Introduce an internal overload or helper: ```ts -function listArmResourcesForResolution( - context: ArmResourceResolutionContext, -): ArmResourceDetails[]; +function listArmResourcesForResolution(context: ArmResourceResolutionContext): ArmResourceDetails[]; ``` For the legacy context it delegates to `listArmResources(program)` after that helper is strengthened @@ -614,12 +607,12 @@ legacy multi-version view and other emitters sharing the program. Use this policy: -| Call shape | Provider cache | -| --- | --- | -| No options | Existing `armResolvedResources` cache | -| Name resolver only | Resolve or clone from the raw legacy result, then transform names; never cache customized output | -| Selected version | Cache the projected snapshot; optionally cache its structural `Provider` by projected namespace | -| Selected version plus name resolver | Reuse the snapshot or structural provider; never cache customized output | +| Call shape | Provider cache | +| ----------------------------------- | ------------------------------------------------------------------------------------------------ | +| No options | Existing `armResolvedResources` cache | +| Name resolver only | Resolve or clone from the raw legacy result, then transform names; never cache customized output | +| Selected version | Cache the projected snapshot; optionally cache its structural `Provider` by projected namespace | +| Selected version plus name resolver | Reuse the snapshot or structural provider; never cache customized output | Add an ARM-owned snapshot cache: @@ -629,7 +622,7 @@ interface ArmVersionSnapshot { realm: unsafe_Realm; } -WeakMap>> +WeakMap>>; ``` This cache is required for correctness and memory stability, not only performance. diff --git a/packages/typespec-azure-resource-manager/src/resource.ts b/packages/typespec-azure-resource-manager/src/resource.ts index 687e058589..2dad01604d 100644 --- a/packages/typespec-azure-resource-manager/src/resource.ts +++ b/packages/typespec-azure-resource-manager/src/resource.ts @@ -498,10 +498,20 @@ function mapResourceKind( } } +interface ArmResourceResolutionContext { + program: Program; + providerNamespace: Namespace; +} + export function resolveArmResources(program: Program): Provider { - const provider = resolveProviderNamespace(program); - if (provider === undefined) return {}; - const resolvedResources = getResolvedResources(program, provider); + const providerNamespace = resolveProviderNamespace(program); + if (providerNamespace === undefined) return {}; + return resolveArmResourcesForContext({ program, providerNamespace }); +} + +function resolveArmResourcesForContext(context: ArmResourceResolutionContext): Provider { + const { program, providerNamespace } = context; + const resolvedResources = getResolvedResources(program, providerNamespace); if (resolvedResources?.resources !== undefined && resolvedResources.resources.length > 0) { // Return the cached resource details return resolvedResources; @@ -537,12 +547,12 @@ export function resolveArmResources(program: Program): Provider { // Add the unmarked operations const resolved: Provider = { resources: resources, - providerOperations: getUnassociatedOperations(program).filter( + providerOperations: getUnassociatedOperationsForContainer(program, providerNamespace).filter( (op) => !isArmResourceOperation(program, op.operation), ), }; - setResolvedResources(program, provider, resolved); + setResolvedResources(program, providerNamespace, resolved); return resolved; } @@ -1098,7 +1108,16 @@ function isResourceIdentityMatch( } export function getUnassociatedOperations(program: Program): ArmResourceOperation[] { - return getAllOperations(program) + const providerNamespace = resolveProviderNamespace(program); + if (providerNamespace === undefined) return []; + return getUnassociatedOperationsForContainer(program, providerNamespace); +} + +function getUnassociatedOperationsForContainer( + program: Program, + container: Namespace | Interface, +): ArmResourceOperation[] { + return getAllOperations(program, container) .map((op) => getResourceOperation(program, op)) .filter((op) => op !== undefined) as ArmResourceOperation[]; } @@ -1131,14 +1150,7 @@ function isArmResourceOperation(program: Program, operation: Operation): boolean return getArmResourceOperationData(program, operation) !== undefined; } -function getAllOperations( - program: Program, - container?: Namespace | Interface | undefined, -): Operation[] { - container = container || resolveProviderNamespace(program); - if (!container) { - return []; - } +function getAllOperations(program: Program, container: Namespace | Interface): Operation[] { const operations: Operation[] = []; for (const op of container.operations.values()) { if ( diff --git a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts index 79c9185130..aa6f2e67bf 100644 --- a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts +++ b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts @@ -473,6 +473,129 @@ describe("unit tests for resource manager helpers", () => { }); }); describe("end-to-end tests for resource manager helpers", () => { + it("preserves the complete legacy provider view when returning the cached result", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +interface Operations extends Azure.ResourceManager.Operations {} + +model Parent is TrackedResource<{}> { + ...ResourceNameParameter; +} + +@parentResource(Parent) +model Child is ProxyResource<{}> { + ...ResourceNameParameter; +} + +@armResourceOperations +interface Parents { + get is ArmResourceRead; +} + +@armResourceOperations +interface Children { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceSync; +} +`); + + const first = resolveArmResources(program); + const second = resolveArmResources(program); + + expect(second).toBe(first); + expect( + first.resources?.map((resource) => ({ + name: resource.resourceName, + type: resource.type.name, + resourceType: resource.resourceType, + path: resource.resourceInstancePath, + parent: resource.parent?.resourceName, + scope: typeof resource.scope === "string" ? resource.scope : resource.scope?.resourceName, + lifecycle: Object.fromEntries( + Object.entries(resource.operations.lifecycle).map(([kind, operations]) => [ + kind, + operations?.map((operation) => ({ + group: operation.operationGroup, + name: operation.name, + path: operation.path, + })), + ]), + ), + })), + ).toEqual([ + { + name: "Parent", + type: "Parent", + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["parents"], + }, + path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}", + parent: undefined, + scope: "ResourceGroup", + lifecycle: { + read: [ + { + group: "Parents", + name: "get", + path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}", + }, + ], + createOrUpdate: undefined, + update: undefined, + delete: undefined, + checkExistence: undefined, + }, + }, + { + name: "Child", + type: "Child", + resourceType: { + provider: "Microsoft.ContosoProviderHub", + types: ["parents", "children"], + }, + path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}/children/{childName}", + parent: "Parent", + scope: "ResourceGroup", + lifecycle: { + read: [ + { + group: "Children", + name: "get", + path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}/children/{childName}", + }, + ], + createOrUpdate: [ + { + group: "Children", + name: "createOrUpdate", + path: "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}/children/{childName}", + }, + ], + update: undefined, + delete: undefined, + checkExistence: undefined, + }, + }, + ]); + expect( + first.providerOperations?.map((operation) => ({ + group: operation.operationGroup, + name: operation.name, + path: operation.path, + })), + ).toEqual([ + { + group: "Operations", + name: "list", + path: "/providers/Microsoft.ContosoProviderHub/operations", + }, + ]); + }, 30_000); it("collects operation information for tracked resources", async () => { const { program } = await Tester.compile(` using Azure.Core; From 7d0277d971720a0ecd7c7c67025222f02f88110f Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Tue, 8 Sep 2026 12:35:07 -0700 Subject: [PATCH 5/7] docs(arm): simplify metadata naming request Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1e98b2e-c932-4db7-9575-469e807e9209 --- .../update-resolve-arm-resources/SKILL.md | 16 ++++-- .../resolve-arm-resources-versioned-view.md | 52 ++++++++++--------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/.github/skills/update-resolve-arm-resources/SKILL.md b/.github/skills/update-resolve-arm-resources/SKILL.md index 30770e109b..35296d8665 100644 --- a/.github/skills/update-resolve-arm-resources/SKILL.md +++ b/.github/skills/update-resolve-arm-resources/SKILL.md @@ -151,8 +151,9 @@ Expose or use a dependency-neutral callback that receives: - projected TypeSpec declaration; - current ARM logical name; - selected version; -- associated resource model when relevant; and -- whether a resource name was explicit ARM metadata. +- associated resource model when relevant; +- ARM resource type string when relevant; and +- resource instance path when the callback needs to distinguish multiple occurrences. Apply naming only after structural resolution. All supported names are non-wire logical metadata: @@ -173,9 +174,16 @@ Never change: Do not describe resource names, operation names, or operation-group names as wire API. Only their current use as internal resolver grouping data requires the post-resolution ordering. +Use `resourceModel` for the TypeSpec `Model` associated with an operation. Use `resourceType` for +the ARM resource type string formatted as `${provider}/${types.join("/")}`. Do not use +`resourceType` to refer to a TypeSpec model. + Keep `ArmResourceOperation.resourceName` and `resourceModelName` consistent with resource naming. -Include resource type and instance path in resource naming requests because one model can produce -several resolved resource occurrences. +Include both the ARM resource type string and instance path in resource naming requests because +one model and one resource type can produce several resolved resource occurrences. For example, +subscription-scoped and tenant-scoped resources can share the same resource type string while +having different instance paths. Resource type alone does not encode scope, parent identifiers, or +extension-resource targets. Track synthetic parents internally and do not invoke model-based naming for them. Current synthetic parents reuse the child model in their `type` field, so model identity alone cannot distinguish diff --git a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md index c04c2bf842..2c91e24ad6 100644 --- a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md +++ b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md @@ -283,20 +283,22 @@ export interface ArmMetadataNameRequest { /** * Resource model associated with operation metadata, when available. */ - resourceType?: Model; + resourceModel?: Model; /** - * ARM identity of the resolved resource occurrence, when available. + * ARM resource type string, when available. * - * These values are context for choosing a logical name and cannot be changed. + * Formatted as `${provider}/${types.join("/")}`. */ - resolvedResourceType?: ResourceType; - resourceInstancePath?: string; + resourceType?: string; /** - * True when ARM metadata explicitly supplied the logical resource name. + * Instance path of the resolved resource occurrence, when available. + * + * Resource type does not uniquely identify an occurrence because the same + * type can be exposed at multiple scopes or beneath different parents. */ - isExplicit?: boolean; + resourceInstancePath?: string; } export type ArmMetadataNameResolver = (request: ArmMetadataNameRequest) => string | undefined; @@ -365,8 +367,20 @@ The implementation must update all aliases of an operation consistently. A singl appear under lifecycle metadata, actions, lists, associated operations, or provider operations. One model can produce several resolved resource occurrences at different paths. Resource naming -requests therefore include `resolvedResourceType` and `resourceInstancePath`; consumers must not -assume that the TypeSpec model alone uniquely identifies a returned resource. +requests therefore include `resourceType` and `resourceInstancePath`; consumers must not assume +that the TypeSpec model or ARM resource type string alone uniquely identifies a returned resource. + +For example, the resolver can return both subscription-scoped and tenant-scoped occurrences of +`Microsoft.ContosoProviderHub/supportTickets`. Both have the same resource type string, but their +instance paths differ: + +```text +/subscriptions/{subscriptionId}/providers/Microsoft.ContosoProviderHub/supportTickets/{supportTicketName} +/providers/Microsoft.ContosoProviderHub/supportTickets/{supportTicketName} +``` + +The instance path is not derivable from the resource type string because scope, parent resource +identifiers, and extension-resource targets are not encoded in the resource type. The resolver does not enforce uniqueness after logical naming. A consumer can intentionally assign the same logical name to multiple resources or operations. Structural association and @@ -664,15 +678,11 @@ nameResolver({ version, defaultName: resource.resourceName, type: resource.type, - resolvedResourceType: resource.resourceType, + resourceType: `${resource.resourceType.provider}/${resource.resourceType.types.join("/")}`, resourceInstancePath: resource.resourceInstancePath, - isExplicit: /* retained from structural resolution */, }); ``` -The current private `resourceNameIsExplicit` value should be retained long enough to populate the -request. It need not become a public `ResolvedResource` property unless another consumer needs it. - A non-empty callback result replaces only `ResolvedResource.resourceName` and corresponding logical operation metadata. It does not replace resource type segments. @@ -687,7 +697,7 @@ nameResolver({ version, defaultName: armOperation.name, type: armOperation.operation, - resourceType, + resourceModel, }); ``` @@ -705,7 +715,7 @@ nameResolver({ version, defaultName: armOperation.operationGroup, type: armOperation.operation.interface, - resourceType, + resourceModel, }); ``` @@ -723,13 +733,6 @@ TCGC-specific precedence remains in TCGC. For example, `getLibraryName` currentl language-scoped `@clientName`, unscoped `@clientName`, `@friendlyName`, generated template names, and the TypeSpec declaration name. -### Explicit logical ARM resource names - -An explicitly supplied logical ARM resource name is included as `defaultName` with -`isExplicit: true`. It is still not wire metadata. The callback is allowed to override it because -the callback is an explicitly requested consumer view. Structural resolution has already -completed, so the override cannot alter resource association. - ## Alternatives considered ### Filter the current multi-version result @@ -803,7 +806,7 @@ expresses the supported customization and preserves invariants. ### Phase 3: Name resolver 1. Add public callback types. -2. Preserve structural name origin and explicitness until post-processing. +2. Preserve the structural logical names until post-processing. 3. Mark synthetic resources so they are excluded from model-based naming. 4. Add graph-preserving copy and name transformation. 5. Test resource, operation, and operation-group names. @@ -884,7 +887,6 @@ Verify: - resource names can change; - operation names can change; - operation-group names can change; -- explicit ARM resource names are passed with `isExplicit: true`; - `undefined` preserves defaults; - empty names report a diagnostic and preserve defaults; - one operation receives a consistent name everywhere it appears; From 72854f405d1265bbc35841c26c37dc7bb39718ac Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Wed, 9 Sep 2026 16:40:57 -0700 Subject: [PATCH 6/7] feat(arm): resolve versioned resource metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1e98b2e-c932-4db7-9575-469e807e9209 --- ...m-resources-context-2026-09-03-18-45-00.md | 4 +- .../update-resolve-arm-resources/SKILL.md | 19 +- .../resolve-arm-resources-versioned-view.md | 24 +- .../src/lib.ts | 30 ++ .../src/private.decorators.ts | 5 +- .../src/resource.ts | 352 +++++++++++++++- .../test/resource-resolution.test.ts | 391 ++++++++++++++++++ .../test/context.test.ts | 38 ++ 8 files changed, 847 insertions(+), 16 deletions(-) diff --git a/.chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md b/.chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md index a19f7581cc..876bebf6de 100644 --- a/.chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md +++ b/.chronus/changes/refactor-resolve-arm-resources-context-2026-09-03-18-45-00.md @@ -1,7 +1,7 @@ --- -changeKind: internal +changeKind: feature packages: - "@azure-tools/typespec-azure-resource-manager" --- -Refactor `resolveArmResources` around an explicit provider resolution context without changing its public behavior. +Add selected-version views and dependency-neutral logical name customization to `resolveArmResources`. The existing no-options call continues to return the multi-version declaration view. diff --git a/.github/skills/update-resolve-arm-resources/SKILL.md b/.github/skills/update-resolve-arm-resources/SKILL.md index 35296d8665..d18b561e86 100644 --- a/.github/skills/update-resolve-arm-resources/SKILL.md +++ b/.github/skills/update-resolve-arm-resources/SKILL.md @@ -100,9 +100,8 @@ For a selected API version: version. 6. Resolve from the cached namespace and realm, not by looking up the provider again. 7. Filter enumerated ARM state to exact realm-owned keys. -8. Validate embedded `Model`, `Operation`, `Interface`, and property references. -9. Clear only derived cache entries for the selected graph. -10. Compute HTTP metadata from projected operations. +8. Use the projected provider namespace as the derived provider-cache key. +9. Compute HTTP metadata from projected operations. `getVersioningMutators` creates new mutator objects each time, and the compiler mutation cache keys on mutator identity. Never recreate a snapshot on every resolver call, and never cache only the @@ -134,8 +133,9 @@ Treat these as derived caches: - `armResolvedResources` - `armResourcesCached` -When adding a state key, document which class it belongs to. Add every new derived cache to the -central invalidation helper. +When adding a state key, document which class it belongs to. Selected snapshots do not clear these +maps: realm-owned keys and the projected provider namespace isolate their derived entries from the +declaration view. Realm state maps can fall back to parent program state for original types. An entry visible from a realm is not necessarily owned by that realm. Check @@ -192,6 +192,15 @@ them. For a TCGC integration test, let the consumer call `getLibraryName` or `getClientNameOverride`. Do not duplicate TCGC precedence in ARM. +The implemented consumer adapter is: + +```ts +resolveArmResources(program, { + version, + nameResolver: ({ type }) => getLibraryName(tcgcContext, type), +}); +``` + ## Required tests Start with failing tests for the requested behavior. diff --git a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md index 2c91e24ad6..7c706c32cd 100644 --- a/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md +++ b/packages/typespec-azure-resource-manager/rfcs/resolve-arm-resources-versioned-view.md @@ -435,9 +435,10 @@ The selected-version path performs these steps: consistency error; it must not fall back to legacy enumeration. 7. Cache the projected namespace and realm by program, provider namespace, and version. 8. Create a realm-aware resolution context. -9. Invalidate derived ARM cache entries for that realm. -10. Resolve resources and operations from the projected namespace and realm-owned metadata. -11. Apply optional logical naming. +9. Resolve resources and operations from the projected namespace and realm-owned metadata. The + projected provider namespace is itself the derived-cache key, so it cannot collide with the + declaration-view cache. +10. Apply optional logical naming. Version matching is exact and case-sensitive because API version enum values are wire values. There is no implicit `latest` value in this API. Consumers that want latest should select it from @@ -454,7 +455,9 @@ Add diagnostics for: and - an empty name returned by a custom name resolver. -The first three diagnostics should include the requested version and available root version values. +The unknown-version diagnostic should include the requested version and available root version +values. Unversioned and transient-version diagnostics include the requested version; transient +versioning has no root service versions to list. The resolver should report the diagnostic through the program and return an empty `Provider`, matching the existing ability to return an empty provider when no ARM provider namespace exists. @@ -820,6 +823,19 @@ expresses the supported customization and preserves invariants. 3. Add a change description for the ARM package. 4. Review whether selected-version diagnostics need a result-plus-diagnostics convenience API. +### Implementation status + +Phases 1 through 4 are implemented by the draft PR associated with this RFC: + +- selected snapshots are cached as projected namespace and realm pairs; +- resource enumeration accepts an exact realm and excludes all realm-owned resources from the + declaration view; +- projected provider namespaces key their own derived provider cache entries; +- logical naming runs on a graph-preserving copy and leaves structural and wire metadata intact; +- synthetic parent resources retain their path-derived names; and +- TCGC integration is exercised from the consumer package by passing `getLibraryName` as the + callback implementation, without an ARM production dependency on TCGC. + ## Test plan ### Compatibility tests diff --git a/packages/typespec-azure-resource-manager/src/lib.ts b/packages/typespec-azure-resource-manager/src/lib.ts index eabe7bee86..a0badc38f9 100644 --- a/packages/typespec-azure-resource-manager/src/lib.ts +++ b/packages/typespec-azure-resource-manager/src/lib.ts @@ -68,6 +68,36 @@ export const $lib = createTypeSpecLibrary({ default: paramMessage`No @armResource registration found for type ${"type"}`, }, }, + "arm-resource-version-not-found": { + severity: "error", + messages: { + default: paramMessage`API version '${"version"}' was not found. Available versions: ${"availableVersions"}.`, + }, + }, + "arm-resource-version-not-supported": { + severity: "error", + messages: { + default: paramMessage`API version '${"version"}' cannot be selected because the ARM service is not versioned.`, + }, + }, + "arm-resource-version-transient": { + severity: "error", + messages: { + default: paramMessage`API version '${"version"}' cannot be selected because the ARM service uses transient versioning.`, + }, + }, + "arm-resource-version-projection-failed": { + severity: "error", + messages: { + default: paramMessage`API version '${"version"}' could not be projected.`, + }, + }, + "arm-resource-invalid-metadata-name": { + severity: "error", + messages: { + default: paramMessage`The metadata name resolver returned an empty ${"kind"} name for '${"name"}'.`, + }, + }, "arm-common-types-incompatible-version": { severity: "warning", messages: { diff --git a/packages/typespec-azure-resource-manager/src/private.decorators.ts b/packages/typespec-azure-resource-manager/src/private.decorators.ts index d6d818df17..96f60de735 100644 --- a/packages/typespec-azure-resource-manager/src/private.decorators.ts +++ b/packages/typespec-azure-resource-manager/src/private.decorators.ts @@ -23,6 +23,7 @@ import { type Tuple, type Type, } from "@typespec/compiler"; +import { unsafe_Realm } from "@typespec/compiler/experimental"; import { $ } from "@typespec/compiler/typekit"; import { useStateMap } from "@typespec/compiler/utils"; @@ -649,13 +650,15 @@ export function registerArmResource( setArmResource(context.program, resourceType, armResourceDetails); } -export function listArmResources(program: Program): ArmResourceDetails[] { +export function listArmResources(program: Program, realm?: unsafe_Realm): ArmResourceDetails[] { // Deduplicate by namespace-qualified name. Versioning mutations (from TCGC's // createSdkContext or autorest's per-version snapshots) re-apply decorators on // realm copies, registering them alongside the originals. By keeping only the // first entry per qualified name, we ensure each resource appears exactly once. const seen = new Set(); return [...armResourceStateMap(program).values()].filter((r) => { + const owningRealm = unsafe_Realm.realmForType.get(r.typespecType); + if (realm === undefined ? owningRealm !== undefined : owningRealm !== realm) return false; const name = getTypeName(r.typespecType); if (seen.has(name)) return false; seen.add(name); diff --git a/packages/typespec-azure-resource-manager/src/resource.ts b/packages/typespec-azure-resource-manager/src/resource.ts index 2dad01604d..45cdeea9ff 100644 --- a/packages/typespec-azure-resource-manager/src/resource.ts +++ b/packages/typespec-azure-resource-manager/src/resource.ts @@ -23,9 +23,11 @@ import { type Program, type Type, } from "@typespec/compiler"; +import { unsafe_mutateSubgraphWithNamespace, unsafe_Realm } from "@typespec/compiler/experimental"; import { useStateMap } from "@typespec/compiler/utils"; import { getHttpOperation, isPathParam } from "@typespec/http"; import { $autoRoute, getParentResource, getSegment } from "@typespec/rest"; +import { getVersioningMutators } from "@typespec/versioning"; import { camelCase, pascalCase } from "change-case"; import type { @@ -61,9 +63,11 @@ import { resolveProviderNamespace, } from "./namespace.js"; import { + type ArmLifecycleOperationKind, type ArmOperationIdentifier, type ArmOperationKind, type ArmResolvedOperationsForResource, + type ArmResourceLifecycleOperations, type ArmResourceOperation, type ArmResourceOperations, getArmResourceOperationData, @@ -148,6 +152,60 @@ export interface Provider { providerOperations?: ArmResourceOperation[]; } +/** + * Options for resolving ARM resource metadata. + * + * @example Resolve one API version and customize logical names + * + * ```ts + * const provider = resolveArmResources(program, { + * version: "2025-01-01", + * nameResolver: ({ type, defaultName }) => getConsumerName(type) ?? defaultName, + * }); + * ``` + */ +export interface ResolveArmResourcesOptions { + /** Exact API version to project before resolving ARM metadata. */ + version?: string; + /** Optional consumer-owned resolver for logical metadata names. */ + nameResolver?: ArmMetadataNameResolver; +} + +/** + * Logical ARM metadata name that can be customized by a consumer. + * + * These names are metadata only and do not change HTTP paths, serialized names, or ARM + * resource-type segments. + */ +export type ArmMetadataNameKind = "resource" | "operation" | "operation-group"; + +/** Context supplied when resolving a logical ARM metadata name. */ +export interface ArmMetadataNameRequest { + /** The kind of logical name being resolved. */ + kind: ArmMetadataNameKind; + /** The TypeSpec program being resolved. */ + program: Program; + /** The selected API version, or undefined for the declaration view. */ + version?: string; + /** The logical name produced by the ARM resolver. */ + defaultName: string; + /** The TypeSpec declaration that owns the logical name. */ + type: Model | Operation | Interface; + /** The associated resource model for operation metadata. */ + resourceModel?: Model; + /** The canonical ARM resource type formatted as `${provider}/${types.join("/")}`. */ + resourceType?: string; + /** The instance path that distinguishes this resolved resource occurrence. */ + resourceInstancePath?: string; +} + +/** + * Resolves a logical ARM metadata name without changing wire API metadata. + * + * Return undefined to preserve the default name. Empty names are rejected with a diagnostic. + */ +export type ArmMetadataNameResolver = (request: ArmMetadataNameRequest) => string | undefined; + export interface ResourcePathInfo { /** The resource type (The actual resource type string will be "${provider}/${types.join("/")}) */ resourceType: ResourceType; @@ -501,16 +559,128 @@ function mapResourceKind( interface ArmResourceResolutionContext { program: Program; providerNamespace: Namespace; + realm?: unsafe_Realm; +} + +interface ArmResourceVersionSnapshot { + providerNamespace: Namespace; + realm: unsafe_Realm; } -export function resolveArmResources(program: Program): Provider { +const armResourceVersionSnapshots = new WeakMap< + Program, + Map> +>(); +const syntheticResources = new WeakSet(); + +/** + * Resolves the multi-version declaration view of ARM resources and operations. + * + * This overload preserves the original resolver behavior. + */ +export function resolveArmResources(program: Program): Provider; +/** + * Resolves ARM resources and operations for an exact API version or with customized logical names. + * + * Selected versions are projected with `@typespec/versioning`, so returned TypeSpec references + * reflect availability, renames, and type changes in that version. + */ +export function resolveArmResources( + program: Program, + options: ResolveArmResourcesOptions, +): Provider; +export function resolveArmResources( + program: Program, + options: ResolveArmResourcesOptions = {}, +): Provider { const providerNamespace = resolveProviderNamespace(program); if (providerNamespace === undefined) return {}; - return resolveArmResourcesForContext({ program, providerNamespace }); + const context = + options.version === undefined + ? { program, providerNamespace } + : resolveArmResourceVersionContext(program, providerNamespace, options.version); + if (context === undefined) return {}; + const provider = resolveArmResourcesForContext(context); + return options.nameResolver === undefined + ? provider + : applyArmMetadataNames(program, provider, options); +} + +function resolveArmResourceVersionContext( + program: Program, + providerNamespace: Namespace, + version: string, +): ArmResourceResolutionContext | undefined { + let providerSnapshots = armResourceVersionSnapshots.get(program); + if (providerSnapshots === undefined) { + providerSnapshots = new Map(); + armResourceVersionSnapshots.set(program, providerSnapshots); + } + let versionSnapshots = providerSnapshots.get(providerNamespace); + if (versionSnapshots === undefined) { + versionSnapshots = new Map(); + providerSnapshots.set(providerNamespace, versionSnapshots); + } + const cached = versionSnapshots.get(version); + if (cached !== undefined) { + return { program, ...cached }; + } + + const versioning = getVersioningMutators(program, providerNamespace); + if (versioning === undefined) { + reportDiagnostic(program, { + code: "arm-resource-version-not-supported", + format: { version }, + target: providerNamespace, + }); + return undefined; + } + if (versioning.kind === "transient") { + reportDiagnostic(program, { + code: "arm-resource-version-transient", + format: { version }, + target: providerNamespace, + }); + return undefined; + } + + const snapshot = versioning.snapshots.find((x) => x.version.value === version); + if (snapshot === undefined) { + reportDiagnostic(program, { + code: "arm-resource-version-not-found", + format: { + version, + availableVersions: versioning.snapshots.map((x) => x.version.value).join(", "), + }, + target: providerNamespace, + }); + return undefined; + } + + const projection = unsafe_mutateSubgraphWithNamespace( + program, + [snapshot.mutator], + providerNamespace, + ); + if (projection.realm === null || projection.type.kind !== "Namespace") { + reportDiagnostic(program, { + code: "arm-resource-version-projection-failed", + format: { version }, + target: providerNamespace, + }); + return undefined; + } + + const resolvedSnapshot = { + providerNamespace: projection.type, + realm: projection.realm, + }; + versionSnapshots.set(version, resolvedSnapshot); + return { program, ...resolvedSnapshot }; } function resolveArmResourcesForContext(context: ArmResourceResolutionContext): Provider { - const { program, providerNamespace } = context; + const { program, providerNamespace, realm } = context; const resolvedResources = getResolvedResources(program, providerNamespace); if (resolvedResources?.resources !== undefined && resolvedResources.resources.length > 0) { // Return the cached resource details @@ -519,7 +689,7 @@ function resolveArmResourcesForContext(context: ArmResourceResolutionContext): P // We haven't generated the full resource details yet const resources: ResolvedResource[] = []; - for (const resource of listArmResources(program)) { + for (const resource of listArmResources(program, realm)) { const operations = resolveArmResourceOperations(program, resource.typespecType); const singletonKeyValues = getSingletonKeyValues(program, resource.typespecType); for (const op of operations) { @@ -556,6 +726,178 @@ function resolveArmResourcesForContext(context: ArmResourceResolutionContext): P return resolved; } +function applyArmMetadataNames( + program: Program, + provider: Provider, + options: ResolveArmResourcesOptions, +): Provider { + const nameResolver = options.nameResolver!; + const resourceCopies = new Map(); + const resources = provider.resources?.map((resource) => { + const copy: ResolvedResource = { + ...resource, + operations: { lifecycle: {}, lists: [], actions: [] }, + associatedOperations: undefined, + parent: undefined, + scope: undefined, + }; + resourceCopies.set(resource, copy); + return copy; + }); + + if (resources !== undefined && provider.resources !== undefined) { + for (let i = 0; i < provider.resources.length; i++) { + const source = provider.resources[i]; + const target = resources[i]; + target.resourceName = syntheticResources.has(source) + ? source.resourceName + : resolveArmMetadataName(program, nameResolver, { + kind: "resource", + program, + version: options.version, + defaultName: source.resourceName, + type: source.type, + resourceType: formatResourceType(source.resourceType), + resourceInstancePath: source.resourceInstancePath, + }); + target.operations = copyResolvedOperations( + program, + nameResolver, + source.operations, + source, + target.resourceName, + options.version, + ); + target.associatedOperations = source.associatedOperations?.map((operation) => + copyArmResourceOperation( + program, + nameResolver, + operation, + source, + target.resourceName, + options.version, + ), + ); + target.parent = source.parent === undefined ? undefined : resourceCopies.get(source.parent); + target.scope = + typeof source.scope === "object" ? resourceCopies.get(source.scope) : source.scope; + } + } + + return { + resources, + providerOperations: provider.providerOperations?.map((operation) => + copyArmResourceOperation( + program, + nameResolver, + operation, + undefined, + undefined, + options.version, + ), + ), + }; +} + +function copyResolvedOperations( + program: Program, + nameResolver: ArmMetadataNameResolver, + operations: ArmResolvedOperationsForResource, + resource: ResolvedResource, + resourceName: string, + version: string | undefined, +): ArmResolvedOperationsForResource { + const lifecycle: ArmResourceLifecycleOperations = {}; + const lifecycleKinds: ArmLifecycleOperationKind[] = [ + "read", + "createOrUpdate", + "update", + "delete", + "checkExistence", + ]; + for (const kind of lifecycleKinds) { + const operationList = operations.lifecycle[kind]; + if (operationList !== undefined) { + lifecycle[kind] = operationList.map((operation) => + copyArmResourceOperation(program, nameResolver, operation, resource, resourceName, version), + ); + } + } + + return { + lifecycle, + lists: operations.lists.map((operation) => + copyArmResourceOperation(program, nameResolver, operation, resource, resourceName, version), + ), + actions: operations.actions.map((operation) => + copyArmResourceOperation(program, nameResolver, operation, resource, resourceName, version), + ), + }; +} + +function copyArmResourceOperation( + program: Program, + nameResolver: ArmMetadataNameResolver, + operation: ArmResourceOperation, + resource: ResolvedResource | undefined, + resourceName: string | undefined, + version: string | undefined, +): ArmResourceOperation { + const requestContext = { + program, + version, + resourceModel: resource?.type, + resourceType: resource === undefined ? undefined : formatResourceType(resource.resourceType), + resourceInstancePath: resource?.resourceInstancePath, + }; + const name = resolveArmMetadataName(program, nameResolver, { + ...requestContext, + kind: "operation", + defaultName: operation.name, + type: operation.operation, + }); + const operationGroup = + operation.operation.interface === undefined + ? operation.operationGroup + : resolveArmMetadataName(program, nameResolver, { + ...requestContext, + kind: "operation-group", + defaultName: operation.operationGroup, + type: operation.operation.interface, + }); + return { + ...operation, + name, + operationGroup, + ...(resourceName === undefined + ? {} + : { + resourceName, + resourceModelName: resourceName, + }), + }; +} + +function resolveArmMetadataName( + program: Program, + nameResolver: ArmMetadataNameResolver, + request: ArmMetadataNameRequest, +): string { + const name = nameResolver(request); + if (name === undefined) return request.defaultName; + if (name.length > 0) return name; + reportDiagnostic(program, { + code: "arm-resource-invalid-metadata-name", + format: { kind: request.kind, name: request.defaultName }, + target: request.type, + }); + return request.defaultName; +} + +function formatResourceType(resourceType: ResourceType): string { + return `${resourceType.provider}/${resourceType.types.join("/")}`; +} + function getResourceParent( knownResources: ResolvedResource[], child: ResolvedResource, @@ -587,6 +929,7 @@ function getResourceParent( .join("/")}`, operations: { lifecycle: {}, actions: [], lists: [] }, }; + syntheticResources.add(parent); knownResources.push(parent); resourcesToProcess.push(parent); return parent; @@ -680,6 +1023,7 @@ function getResourceScope( resourceInstancePath: `/${segments.join("/")}`, operations: { lifecycle: {}, actions: [], lists: [] }, }; + syntheticResources.add(parent); for (const knownResource of knownResources) { if ( parent.resourceType.provider.toLowerCase() === diff --git a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts index aa6f2e67bf..52fd82ef6b 100644 --- a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts +++ b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts @@ -1,3 +1,5 @@ +import { unsafe_Realm } from "@typespec/compiler/experimental"; +import { expectDiagnostics } from "@typespec/compiler/testing"; import { ok } from "assert"; import { describe, expect, it } from "vitest"; import type { ArmOperationKind, ArmResourceOperation } from "../src/operations.js"; @@ -596,6 +598,395 @@ interface Children { }, ]); }, 30_000); + + it("resolves resources and operations for a selected API version", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +@versioned(Versions) +namespace Microsoft.ContosoProviderHub; + +enum Versions { + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v1: "2024-01-01", + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2: "2025-01-01", + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v3: "2026-01-01", +} + +@renamedFrom(Versions.v2, "OldWidget") +model Widget is TrackedResource { + ...ResourceNameParameter; +} + +model WidgetProperties { + @typeChangedFrom(Versions.v2, string) + value: int32; + + @added(Versions.v2) + currentOnly?: string; + + @madeOptional(Versions.v3) + flexible?: string; +} + +@added(Versions.v2) +model Gadget is ProxyResource<{}> { + ...ResourceNameParameter; +} + +@added(Versions.v2) +@removed(Versions.v3) +model Temporary is ProxyResource<{}> { + ...ResourceNameParameter; +} + +interface Operations extends Azure.ResourceManager.Operations {} + +@added(Versions.v2) +@route("/providers/Microsoft.ContosoProviderHub/status") +interface ProviderStatus { + @returnTypeChangedFrom(Versions.v3, string) + @get status(): int32; +} + +@renamedFrom(Versions.v2, "OldWidgets") +@armResourceOperations +interface Widgets { + @renamedFrom(Versions.v2, "fetch") + get is ArmResourceRead; + + @added(Versions.v2) + createOrUpdate is ArmResourceCreateOrReplaceSync; + + @added(Versions.v2) + @removed(Versions.v3) + update is ArmResourcePatchSync; +} + +@added(Versions.v2) +@armResourceOperations +interface Gadgets { + get is ArmResourceRead; +} + +@added(Versions.v2) +@removed(Versions.v3) +@armResourceOperations +interface Temporaries { + get is ArmResourceRead; +} +`); + + const legacyBeforeProjection = resolveArmResources(program); + const v1 = resolveArmResources(program, { version: "2024-01-01" }); + const repeatedV1 = resolveArmResources(program, { version: "2024-01-01" }); + const namedV1 = resolveArmResources(program, { + version: "2024-01-01", + nameResolver: ({ kind, defaultName }) => + kind === "operation" ? `client${defaultName}` : `Client${defaultName}`, + }); + const differentlyNamedV1 = resolveArmResources(program, { + version: "2024-01-01", + nameResolver: ({ defaultName }) => `Other${defaultName}`, + }); + const v2 = resolveArmResources(program, { version: "2025-01-01" }); + const v3 = resolveArmResources(program, { version: "2026-01-01" }); + + expect(repeatedV1).toBe(v1); + expect(namedV1).not.toBe(v1); + expect(namedV1.resources?.[0].resourceName).toBe("ClientOldWidget"); + expect(namedV1.resources?.[0].operations.lifecycle.read?.[0]).toMatchObject({ + name: "clientfetch", + operationGroup: "ClientOldWidgets", + resourceName: "ClientOldWidget", + }); + expect(differentlyNamedV1.resources?.[0].resourceName).toBe("OtherOldWidget"); + expect(v1.resources).toHaveLength(1); + const oldWidget = v1.resources![0]; + expect(oldWidget.type.name).toBe("OldWidget"); + expect(oldWidget.resourceName).toBe("OldWidget"); + expect(oldWidget.operations.lifecycle.read?.[0]).toMatchObject({ + name: "fetch", + operationGroup: "OldWidgets", + }); + expect(oldWidget.operations.lifecycle.createOrUpdate).toBeUndefined(); + expect(v1.providerOperations?.map((x) => x.name)).not.toContain("status"); + + const oldWidgetRealm = unsafe_Realm.realmForType.get(oldWidget.type); + expect(oldWidgetRealm).toBeDefined(); + expect(unsafe_Realm.realmForType.get(repeatedV1.resources![0].type)).toBe(oldWidgetRealm); + + const oldProperties = oldWidget.type.properties.get("properties")?.type; + ok(oldProperties?.kind === "Model"); + expect(oldProperties.properties.has("currentOnly")).toBe(false); + expect(oldProperties.properties.get("value")?.type).toMatchObject({ + kind: "Scalar", + name: "string", + }); + + expect(v2.resources?.map((x) => x.type.name).sort()).toEqual(["Gadget", "Temporary", "Widget"]); + const widget = v2.resources!.find((x) => x.type.name === "Widget"); + ok(widget); + expect(widget.operations.lifecycle.read?.[0]).toMatchObject({ + name: "get", + operationGroup: "Widgets", + }); + expect(widget.operations.lifecycle.createOrUpdate).toHaveLength(1); + expect(widget.operations.lifecycle.update).toHaveLength(1); + expect(v2.providerOperations?.map((x) => x.name)).toContain("status"); + expect( + v2.providerOperations?.find((x) => x.name === "status")?.operation.returnType, + ).toMatchObject({ + kind: "Scalar", + name: "string", + }); + + const currentProperties = widget.type.properties.get("properties")?.type; + ok(currentProperties?.kind === "Model"); + expect(currentProperties.properties.has("currentOnly")).toBe(true); + expect(currentProperties.properties.get("value")?.type).toMatchObject({ + kind: "Scalar", + name: "int32", + }); + expect(currentProperties.properties.get("flexible")?.optional).toBe(false); + + expect(v3.resources?.map((x) => x.type.name).sort()).toEqual(["Gadget", "Widget"]); + const latestWidget = v3.resources!.find((x) => x.type.name === "Widget"); + ok(latestWidget); + expect(latestWidget.operations.lifecycle.update).toBeUndefined(); + const latestProperties = latestWidget.type.properties.get("properties")?.type; + ok(latestProperties?.kind === "Model"); + expect(latestProperties.properties.get("flexible")?.optional).toBe(true); + expect( + v3.providerOperations?.find((x) => x.name === "status")?.operation.returnType, + ).toMatchObject({ + kind: "Scalar", + name: "int32", + }); + + const legacy = resolveArmResources(program); + expect(legacy).toBe(legacyBeforeProjection); + expect(legacy.resources?.map((x) => x.type.name).sort()).toEqual([ + "Gadget", + "Temporary", + "Widget", + ]); + expect(legacy.resources?.some((x) => unsafe_Realm.realmForType.has(x.type))).toBe(false); + }, 30_000); + + it("reports invalid selected API versions", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +@versioned(Versions) +namespace Microsoft.ContosoProviderHub; + +enum Versions { + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v1: "2024-01-01", +} + +model Widget is TrackedResource<{}> { + ...ResourceNameParameter; +} + +@armResourceOperations +interface Widgets { + get is ArmResourceRead; +} +`); + + expect(resolveArmResources(program, { version: "2099-01-01" })).toEqual({}); + expectDiagnostics(program.diagnostics, { + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-version-not-found", + message: "API version '2099-01-01' was not found. Available versions: 2024-01-01.", + }); + }); + + it("reports a selected API version for a transiently versioned service", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +model Widget is TrackedResource<{}> { + ...ResourceNameParameter; +} + +@armResourceOperations +interface Widgets { + get is ArmResourceRead; +} +`); + + expect(resolveArmResources(program, { version: "2024-01-01" })).toEqual({}); + expectDiagnostics(program.diagnostics, { + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-version-transient", + message: + "API version '2024-01-01' cannot be selected because the ARM service uses transient versioning.", + }); + }); + + it("customizes logical names without mutating the cached provider", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +interface Operations extends Azure.ResourceManager.Operations {} + +model Parent is TrackedResource<{}> { + ...ResourceNameParameter; +} + +@parentResource(Parent) +model Child is ProxyResource<{}> { + ...ResourceNameParameter; +} + +@armResourceOperations +interface Children { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceSync; +} +`); + + const original = resolveArmResources(program); + const requests: Array<{ + kind: string; + defaultName: string; + typeName: string; + resourceModel?: string; + resourceType?: string; + resourceInstancePath?: string; + }> = []; + const named = resolveArmResources(program, { + nameResolver: (request) => { + requests.push({ + kind: request.kind, + defaultName: request.defaultName, + typeName: String(request.type.name), + resourceModel: request.resourceModel?.name, + resourceType: request.resourceType, + resourceInstancePath: request.resourceInstancePath, + }); + switch (request.kind) { + case "resource": + return `Client${request.defaultName}`; + case "operation": + return `client${request.defaultName}`; + case "operation-group": + return `Client${request.defaultName}`; + } + }, + }); + + expect(named).not.toBe(original); + const child = named.resources?.find((x) => x.type.name === "Child"); + ok(child); + expect(child.resourceName).toBe("ClientChild"); + expect(child.operations.lifecycle.read?.[0]).toMatchObject({ + name: "clientget", + operationGroup: "ClientChildren", + resourceName: "ClientChild", + resourceModelName: "ClientChild", + }); + + const syntheticParent = named.resources?.find( + (x) => x.resourceType.types.join("/") === "parents", + ); + ok(syntheticParent); + expect(syntheticParent.resourceName).toBe("Parent"); + + expect(requests).toContainEqual({ + kind: "resource", + defaultName: "Child", + typeName: "Child", + resourceModel: undefined, + resourceType: "Microsoft.ContosoProviderHub/parents/children", + resourceInstancePath: + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}/children/{childName}", + }); + expect(requests).toContainEqual({ + kind: "operation", + defaultName: "get", + typeName: "get", + resourceModel: "Child", + resourceType: "Microsoft.ContosoProviderHub/parents/children", + resourceInstancePath: + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}/children/{childName}", + }); + expect(requests).toContainEqual({ + kind: "operation-group", + defaultName: "Children", + typeName: "Children", + resourceModel: "Child", + resourceType: "Microsoft.ContosoProviderHub/parents/children", + resourceInstancePath: + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.ContosoProviderHub/parents/{parentName}/children/{childName}", + }); + + expect(resolveArmResources(program)).toBe(original); + expect(original.resources?.find((x) => x.type.name === "Child")?.resourceName).toBe("Child"); + expect( + original.resources?.find((x) => x.type.name === "Child")?.operations.lifecycle.read?.[0], + ).toMatchObject({ + name: "get", + operationGroup: "Children", + resourceName: "Child", + resourceModelName: "Child", + }); + }, 30_000); + + it("handles metadata name resolver fallback, empty names, and errors", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +model Widget is TrackedResource<{}> { + ...ResourceNameParameter; +} + +@armResourceOperations +interface Widgets { + get is ArmResourceRead; +} +`); + + const original = resolveArmResources(program); + const fallback = resolveArmResources(program, { + nameResolver: () => undefined, + }); + expect(fallback).not.toBe(original); + expect(fallback.resources?.[0].resourceName).toBe("Widget"); + + const empty = resolveArmResources(program, { + nameResolver: ({ kind }) => (kind === "resource" ? "" : undefined), + }); + expect(empty.resources?.[0].resourceName).toBe("Widget"); + expectDiagnostics(program.diagnostics, { + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-metadata-name", + message: "The metadata name resolver returned an empty resource name for 'Widget'.", + }); + + expect(() => + resolveArmResources(program, { + nameResolver: () => { + throw new Error("consumer naming failed"); + }, + }), + ).toThrow("consumer naming failed"); + expect(resolveArmResources(program)).toBe(original); + }); + it("collects operation information for tracked resources", async () => { const { program } = await Tester.compile(` using Azure.Core; diff --git a/packages/typespec-client-generator-core/test/context.test.ts b/packages/typespec-client-generator-core/test/context.test.ts index 9f6b5c78b6..048e33db16 100644 --- a/packages/typespec-client-generator-core/test/context.test.ts +++ b/packages/typespec-client-generator-core/test/context.test.ts @@ -5,6 +5,7 @@ import { it } from "vitest"; import { parse } from "yaml"; import { createSdkContext } from "../src/context.js"; import { listClients } from "../src/decorators.js"; +import { getLibraryName } from "../src/public-utils.js"; import { SdkTestLibrary } from "../src/testing/index.js"; import { ArmTester, createSdkContextForTester, SimpleTester } from "./tester.js"; @@ -263,3 +264,40 @@ it("calling createSdkContext does not cause resolveArmResources to return duplic ok(resourceNames.includes("EmployeeParent")); ok(resourceNames.includes("Employee")); }); + +it("uses TCGC library names when supplied to resolveArmResources", async () => { + const { program } = await ArmTester.compile(` + @armProviderNamespace + @service(#{ title: "Azure Management emitter Testing" }) + namespace Microsoft.ContosoProviderHub; + + @clientName("ClientWidget") + model Widget is TrackedResource<{}> { + ...ResourceNameParameter; + } + + interface Operations extends Azure.ResourceManager.Operations {} + + @clientName("ClientWidgets", "csharp") + @armResourceOperations + interface Widgets { + @clientName("fetchWidget", "csharp") + get is ArmResourceRead; + } + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + const provider = resolveArmResources(program, { + nameResolver: ({ type }) => getLibraryName(context, type), + }); + + const widget = provider.resources?.find((x) => x.type.name === "Widget"); + ok(widget); + strictEqual(widget.resourceName, "ClientWidget"); + strictEqual(widget.operations.lifecycle.read?.[0].name, "fetchWidget"); + strictEqual(widget.operations.lifecycle.read?.[0].operationGroup, "ClientWidgets"); + strictEqual(widget.operations.lifecycle.read?.[0].resourceName, "ClientWidget"); + strictEqual(widget.operations.lifecycle.read?.[0].resourceModelName, "ClientWidget"); +}); From 3f0f07654673396e092d47bb618ecf28a637cfa2 Mon Sep 17 00:00:00 2001 From: Mark Cowlishaw Date: Wed, 9 Sep 2026 17:31:32 -0700 Subject: [PATCH 7/7] test(arm): expand versioned naming coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1e98b2e-c932-4db7-9575-469e807e9209 --- .../test/resource-resolution.test.ts | 216 +++++++++++++++++- .../test/context.test.ts | 119 ++++++++++ 2 files changed, 332 insertions(+), 3 deletions(-) diff --git a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts index 52fd82ef6b..5795f10ee5 100644 --- a/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts +++ b/packages/typespec-azure-resource-manager/test/resource-resolution.test.ts @@ -944,6 +944,196 @@ interface Children { }); }, 30_000); + it("customizes every operation category without changing wire metadata", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +interface Operations extends Azure.ResourceManager.Operations {} + +model Employee is TrackedResource { + ...ResourceNameParameter; +} + +model EmployeeProperties { + value?: string; +} + +model MoveRequest { + destination: string; +} + +model MoveResponse { + status: string; +} + +@armResourceOperations +interface Employees { + get is ArmResourceRead; + createOrUpdate is ArmResourceCreateOrReplaceSync; + update is ArmCustomPatchSync< + Employee, + Azure.ResourceManager.Foundations.ResourceUpdateModel + >; + delete is ArmResourceDeleteSync; + checkExistence is ArmResourceCheckExistence; + listByResourceGroup is ArmResourceListByParent; + move is ArmResourceActionSync; +} +`); + + const original = resolveArmResources(program); + const named = resolveArmResources(program, { + nameResolver: ({ kind, defaultName }) => + kind === "operation" ? `client${defaultName}` : `Client${defaultName}`, + }); + + const originalEmployee = original.resources?.find((x) => x.type.name === "Employee"); + const employee = named.resources?.find((x) => x.type.name === "Employee"); + ok(originalEmployee); + ok(employee); + expect(employee.resourceName).toBe("ClientEmployee"); + expect(employee.resourceType).toEqual(originalEmployee.resourceType); + expect(employee.resourceInstancePath).toBe(originalEmployee.resourceInstancePath); + + for (const kind of ["read", "createOrUpdate", "update", "delete", "checkExistence"] as const) { + const originalOperation = originalEmployee.operations.lifecycle[kind]?.[0]; + const operation = employee.operations.lifecycle[kind]?.[0]; + ok(originalOperation); + ok(operation); + expect(operation.name).toBe(`client${originalOperation.name}`); + expect(operation.operationGroup).toBe("ClientEmployees"); + expect(operation.resourceName).toBe("ClientEmployee"); + expect(operation.resourceModelName).toBe("ClientEmployee"); + expect(operation.path).toBe(originalOperation.path); + expect(operation.httpOperation.verb).toBe(originalOperation.httpOperation.verb); + } + + const originalList = originalEmployee.operations.lists[0]; + const list = employee.operations.lists[0]; + expect(list).toMatchObject({ + name: "clientlistByResourceGroup", + operationGroup: "ClientEmployees", + resourceName: "ClientEmployee", + resourceModelName: "ClientEmployee", + }); + expect(list.path).toBe(originalList.path); + expect(list.httpOperation.verb).toBe(originalList.httpOperation.verb); + + const originalAction = originalEmployee.operations.actions[0]; + const action = employee.operations.actions[0]; + expect(action).toMatchObject({ + name: "clientmove", + operationGroup: "ClientEmployees", + resourceName: "ClientEmployee", + resourceModelName: "ClientEmployee", + }); + expect(action.path).toBe(originalAction.path); + expect(action.httpOperation.verb).toBe(originalAction.httpOperation.verb); + + expect(employee.associatedOperations).toEqual([]); + expect(employee.associatedOperations).not.toBe(originalEmployee.associatedOperations); + + const originalProviderOperation = original.providerOperations?.find((x) => x.name === "list"); + const providerOperation = named.providerOperations?.find((x) => x.name === "clientlist"); + ok(originalProviderOperation); + ok(providerOperation); + expect(providerOperation.operationGroup).toBe("ClientOperations"); + expect(providerOperation.path).toBe(originalProviderOperation.path); + expect(providerOperation.httpOperation.verb).toBe(originalProviderOperation.httpOperation.verb); + }, 30_000); + + it("preserves parent and resource-valued scope references in customized graphs", async () => { + const { program } = await Tester.compile(` +using Azure.Core; + +@armProviderNamespace +namespace Microsoft.ContosoProviderHub; + +model Parent is TrackedResource<{}> { + ...ResourceNameParameter; +} + +@parentResource(Parent) +model Child is ProxyResource<{}> { + ...ResourceNameParameter; +} + +model OrphanParent is TrackedResource<{}> { + ...ResourceNameParameter; +} + +@parentResource(OrphanParent) +model OrphanChild is ProxyResource<{}> { + ...ResourceNameParameter; +} + +model ExtensionWidget is ExtensionResource<{}> { + ...ResourceNameParameter; +} + +alias VirtualMachine = Extension.ExternalResource< + "Microsoft.Compute", + "virtualMachines", + "vmName" +>; + +@armResourceOperations +interface Parents { + get is ArmResourceRead; +} + +@armResourceOperations +interface Children { + get is ArmResourceRead; +} + +@armResourceOperations +interface OrphanChildren { + get is ArmResourceRead; +} + +@armResourceOperations +interface ExtensionWidgets { + get is Extension.Read; +} +`); + + const original = resolveArmResources(program); + const named = resolveArmResources(program, { + nameResolver: ({ defaultName }) => `Client${defaultName}`, + }); + + const parent = named.resources?.find((x) => x.type.name === "Parent"); + const child = named.resources?.find((x) => x.type.name === "Child"); + ok(parent); + ok(child); + expect(child.parent).toBe(parent); + expect(child.parent).not.toBe(original.resources?.find((x) => x.type.name === "Child")?.parent); + expect(parent.resourceName).toBe("ClientParent"); + expect(child.resourceName).toBe("ClientChild"); + + const orphanChild = named.resources?.find( + (x) => x.type.name === "OrphanChild" && x.resourceType.types.at(-1) === "orphanChildren", + ); + ok(orphanChild); + ok(orphanChild.parent); + expect(orphanChild.parent.resourceName).toBe("OrphanParent"); + expect(named.resources).toContain(orphanChild.parent); + + const extensionWidget = named.resources?.find((x) => x.type.name === "ExtensionWidget"); + ok(extensionWidget); + expect(typeof extensionWidget.scope).toBe("object"); + ok(typeof extensionWidget.scope === "object"); + expect(extensionWidget.scope.resourceName).toBe("VirtualMachine"); + expect(named.resources).toContain(extensionWidget.scope); + expect(extensionWidget.scope).not.toBe( + original.resources?.find((x) => x.type.name === "ExtensionWidget")?.scope, + ); + }, 30_000); + it("handles metadata name resolver fallback, empty names, and errors", async () => { const { program } = await Tester.compile(` using Azure.Core; @@ -972,10 +1162,30 @@ interface Widgets { nameResolver: ({ kind }) => (kind === "resource" ? "" : undefined), }); expect(empty.resources?.[0].resourceName).toBe("Widget"); - expectDiagnostics(program.diagnostics, { - code: "@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-metadata-name", - message: "The metadata name resolver returned an empty resource name for 'Widget'.", + const emptyOperation = resolveArmResources(program, { + nameResolver: ({ kind }) => (kind === "operation" ? "" : undefined), + }); + expect(emptyOperation.resources?.[0].operations.lifecycle.read?.[0].name).toBe("get"); + const emptyOperationGroup = resolveArmResources(program, { + nameResolver: ({ kind }) => (kind === "operation-group" ? "" : undefined), }); + expect(emptyOperationGroup.resources?.[0].operations.lifecycle.read?.[0].operationGroup).toBe( + "Widgets", + ); + expectDiagnostics(program.diagnostics, [ + { + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-metadata-name", + message: "The metadata name resolver returned an empty resource name for 'Widget'.", + }, + { + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-metadata-name", + message: "The metadata name resolver returned an empty operation name for 'get'.", + }, + { + code: "@azure-tools/typespec-azure-resource-manager/arm-resource-invalid-metadata-name", + message: "The metadata name resolver returned an empty operation-group name for 'Widgets'.", + }, + ]); expect(() => resolveArmResources(program, { diff --git a/packages/typespec-client-generator-core/test/context.test.ts b/packages/typespec-client-generator-core/test/context.test.ts index 048e33db16..1c362f14dd 100644 --- a/packages/typespec-client-generator-core/test/context.test.ts +++ b/packages/typespec-client-generator-core/test/context.test.ts @@ -1,4 +1,5 @@ import { resolveArmResources } from "@azure-tools/typespec-azure-resource-manager"; +import { unsafe_Realm } from "@typespec/compiler/experimental"; import { resolveVirtualPath } from "@typespec/compiler/testing"; import { ok, strictEqual } from "assert"; import { it } from "vitest"; @@ -301,3 +302,121 @@ it("uses TCGC library names when supplied to resolveArmResources", async () => { strictEqual(widget.operations.lifecycle.read?.[0].resourceName, "ClientWidget"); strictEqual(widget.operations.lifecycle.read?.[0].resourceModelName, "ClientWidget"); }); + +it("uses TCGC library names for selected ARM resource versions", async () => { + const { program } = await ArmTester.compile(` + @armProviderNamespace + @service(#{ title: "Azure Management emitter Testing" }) + @versioned(Versions) + namespace Microsoft.ContosoProviderHub; + + enum Versions { + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v1: "2024-01-01", + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v2: "2025-01-01", + @armCommonTypesVersion(Azure.ResourceManager.CommonTypes.Versions.v5) + v3: "2026-01-01", + } + + @clientName("ClientWidget") + model Widget is TrackedResource { + ...ResourceNameParameter; + } + + model WidgetProperties { + value?: string; + } + + @added(Versions.v2) + @clientName("ClientGadget", "csharp") + model Gadget is ProxyResource<{}> { + ...ResourceNameParameter; + } + + @added(Versions.v2) + @removed(Versions.v3) + @clientName("ClientTemporary") + model Temporary is ProxyResource<{}> { + ...ResourceNameParameter; + } + + @clientName("ClientWidgets", "csharp") + @armResourceOperations + interface Widgets { + @clientName("fetchWidget", "csharp") + get is ArmResourceRead; + + @added(Versions.v2) + @clientName("createWidget", "csharp") + createOrUpdate is ArmResourceCreateOrReplaceSync; + + @added(Versions.v2) + @removed(Versions.v3) + @clientName("updateWidget", "csharp") + update is ArmResourcePatchSync; + } + + @added(Versions.v2) + @clientName("ClientGadgets", "csharp") + @armResourceOperations + interface Gadgets { + @clientName("fetchGadget", "csharp") + get is ArmResourceRead; + } + + @added(Versions.v2) + @removed(Versions.v3) + @clientName("ClientTemporaries") + @armResourceOperations + interface Temporaries { + @clientName("fetchTemporary") + get is ArmResourceRead; + } + `); + + const context = await createSdkContextForTester(program, { + emitterName: "@azure-tools/typespec-csharp", + }); + const resolveVersion = (version: string) => + resolveArmResources(program, { + version, + nameResolver: ({ type }) => getLibraryName(context, type), + }); + + const v1 = resolveVersion("2024-01-01"); + const v2 = resolveVersion("2025-01-01"); + const v3 = resolveVersion("2026-01-01"); + + strictEqual(v1.resources?.length, 1); + const v1Widget = v1.resources?.[0]; + ok(v1Widget); + strictEqual(v1Widget.resourceName, "ClientWidget"); + strictEqual(v1Widget.operations.lifecycle.read?.[0].name, "fetchWidget"); + strictEqual(v1Widget.operations.lifecycle.read?.[0].operationGroup, "ClientWidgets"); + strictEqual(v1Widget.operations.lifecycle.createOrUpdate, undefined); + ok(unsafe_Realm.realmForType.has(v1Widget.type)); + + strictEqual(v2.resources?.length, 3); + const v2Widget = v2.resources?.find((x) => x.type.name === "Widget"); + const v2Gadget = v2.resources?.find((x) => x.type.name === "Gadget"); + const v2Temporary = v2.resources?.find((x) => x.type.name === "Temporary"); + ok(v2Widget); + ok(v2Gadget); + ok(v2Temporary); + strictEqual(v2Widget.resourceName, "ClientWidget"); + strictEqual(v2Widget.operations.lifecycle.createOrUpdate?.[0].name, "createWidget"); + strictEqual(v2Widget.operations.lifecycle.update?.[0].name, "updateWidget"); + strictEqual(v2Gadget.resourceName, "ClientGadget"); + strictEqual(v2Gadget.operations.lifecycle.read?.[0].operationGroup, "ClientGadgets"); + strictEqual(v2Temporary.resourceName, "ClientTemporary"); + strictEqual(v2Temporary.operations.lifecycle.read?.[0].name, "fetchTemporary"); + + strictEqual(v3.resources?.length, 2); + const v3Widget = v3.resources?.find((x) => x.type.name === "Widget"); + ok(v3Widget); + strictEqual(v3Widget.resourceName, "ClientWidget"); + strictEqual(v3Widget.operations.lifecycle.update, undefined); + ok(v3.resources?.some((x) => x.resourceName === "ClientGadget")); + ok(!v3.resources?.some((x) => x.resourceName === "ClientTemporary")); +});