Describe the bug
When a Bicep-compiled ARM template uses languageVersion: "2.0" (symbolic name codegen — triggered e.g. by any compile-time import statement) and contains a cross-scope existing resource, reference() calls against that resource use the bare symbolic name as the first argument (e.g. reference('vnet').addressSpace.addressPrefixes) instead of reference(extensionResourceId(...), apiVersion, 'full').
This is valid, standard ARM syntax for languageVersion 2.0 templates — real Azure deployments resolve it fine because ARM evaluates symbolic references against the resource's declared scope.
However, PSRule's offline expansion (used by GetBicepParamResources / pre-flight bicep(param) expansion, e.g. via ps-rule-assert@2) fails to resolve this form. Downstream use of the result in a strict-typed function such as concat()/map() then throws:
An error occurred evaluating expression '[map(concat(reference('resourceA', ...), reference('resourceB', ...)), ...)]' ... The function 'map' failed. The function 'concat' failed. The arguments for 'Concat' are not in the expected format or type.
Scalar property assignments elsewhere in the same template hit the identical broken resolution but don't surface as hard errors, since plain string/object fields tolerate an unresolved/mock value — making this easy to miss until it feeds an array function.
Root cause (traced in source)
DeploymentVisitor.Emit() (src/PSRule.Rules.Azure/Arm/Deployments/DeploymentVisitor.cs) explicitly skips registering existing resources:
protected virtual void Emit(TemplateContext context, IResourceValue resource)
{
if (resource == null || resource.IsExisting())
return;
...
context.AddResource(resource);
}
- For the symbolic-name (
languageVersion 2.0) resources-as-object form, DeploymentVisitor.Reference(context, symbolicName, resource) creates an ExistingResourceValue and registers it only as a symbol (context.AddSymbol(symbol)), never via context.AddResource(...). So the resource's id is never added to TemplateContext._ResourceIds.
TemplateContext.TryGetResource(nameOrResourceId, ...) resolves a symbolic name to an id via symbol.GetId(0), then looks that id up in _ResourceIds — which never contains it for existing resources, so the lookup fails.
Functions.Reference(context, args) then falls back to a placeholder:
return context.TryGetResource(resourceId, out var resourceValue)
? GetReferenceResult(resourceValue, full)
: full ? new Mock.MockResource(resourceId) : new Mock.MockResource(resourceId)["properties"];
Mock.MockResource(resourceId) (src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs) expects resourceId to be a full ARM resource ID and parses it via ResourceHelper.TryResourceIdComponents(...) to build id/type/name/properties. When resourceId is actually just the bare symbolic name (e.g. "vnet"), that parsing produces a malformed/incomplete mock object whose nested property values are not usable as arrays/strings by strict functions like concat()/map().
Note that ObjectDeploymentSymbol/ArrayDeploymentSymbol already retain a reference to the real IResourceValue via Configure(resource) — it's discarded after computing the id string, which is what forces the failed _ResourceIds round-trip in step 3.
To Reproduce
other.bicep
@export()
var unused = 'unused'
main.bicep
targetScope = 'resourceGroup'
import { unused } from 'other.bicep'
resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' existing = {
scope: resourceGroup('other-rg')
name: 'vnet1'
}
output addressPrefixes array = vnet.properties.addressSpace.addressPrefixes
bicep build main.bicep --stdout (with the import present) produces languageVersion 2.0 output using the bare symbolic form:
"addressPrefixes": {
"type": "array",
"value": "[reference('vnet').addressSpace.addressPrefixes]"
}
Running this through PSRule's pre-flight expansion (Assert-PSRule/ps-rule-assert@2 against the compiled template, or directly via GetBicepParamResources) fails to resolve addressPrefixes correctly and can throw when the value feeds concat()/map() elsewhere in the template.
Removing the import line removes the trigger for languageVersion 2.0 and restores the extensionResourceId(...)-based codegen, which — while still ultimately routed through the same Mock.MockResource fallback for existing resources — receives a well-formed resource ID and therefore avoids the malformed mock.
Expected behavior
PSRule's expansion of existing cross-scope resources should resolve consistently regardless of whether the compiled template uses the resourceId(...)/extensionResourceId(...) form or the bare symbolic-name form used by languageVersion 2.0 templates, so that property access (including through concat()/map()) does not fail or silently produce malformed values.
Suggested fix direction
Rather than round-tripping symbolic names through TemplateContext._ResourceIds (which by design never contains existing resources), TryGetResource could resolve the actual IResourceValue directly from the matched symbol — e.g. by extending IDeploymentSymbol with a GetResource(int index) accessor alongside the existing GetId(int index), and having ObjectDeploymentSymbol/ArrayDeploymentSymbol return the IResourceValue they already hold via Configure(...). Functions.Reference/Functions.References would then get the real ExistingResourceValue (which already computes scope-aware Id/Name) instead of falling back to Mock.MockResource with an unparsable identifier.
This looks like a contained change scoped to Arm/Symbols/*, Arm/Deployments/TemplateContext.cs, and Arm/Expressions/Functions.cs, testable with the existing Arm/Deployments/Arm/Expressions unit test suites — not a large architectural change.
Environment
Describe the bug
When a Bicep-compiled ARM template uses
languageVersion: "2.0"(symbolic name codegen — triggered e.g. by any compile-timeimportstatement) and contains a cross-scopeexistingresource,reference()calls against that resource use the bare symbolic name as the first argument (e.g.reference('vnet').addressSpace.addressPrefixes) instead ofreference(extensionResourceId(...), apiVersion, 'full').This is valid, standard ARM syntax for
languageVersion 2.0templates — real Azure deployments resolve it fine because ARM evaluates symbolic references against the resource's declaredscope.However, PSRule's offline expansion (used by
GetBicepParamResources/ pre-flight bicep(param) expansion, e.g. viaps-rule-assert@2) fails to resolve this form. Downstream use of the result in a strict-typed function such asconcat()/map()then throws:Scalar property assignments elsewhere in the same template hit the identical broken resolution but don't surface as hard errors, since plain string/object fields tolerate an unresolved/mock value — making this easy to miss until it feeds an array function.
Root cause (traced in source)
DeploymentVisitor.Emit()(src/PSRule.Rules.Azure/Arm/Deployments/DeploymentVisitor.cs) explicitly skips registering existing resources:languageVersion 2.0) resources-as-object form,DeploymentVisitor.Reference(context, symbolicName, resource)creates anExistingResourceValueand registers it only as a symbol (context.AddSymbol(symbol)), never viacontext.AddResource(...). So the resource's id is never added toTemplateContext._ResourceIds.TemplateContext.TryGetResource(nameOrResourceId, ...)resolves a symbolic name to an id viasymbol.GetId(0), then looks that id up in_ResourceIds— which never contains it for existing resources, so the lookup fails.Functions.Reference(context, args)then falls back to a placeholder:Mock.MockResource(resourceId)(src/PSRule.Rules.Azure/Arm/Mocks/Mock.cs) expectsresourceIdto be a full ARM resource ID and parses it viaResourceHelper.TryResourceIdComponents(...)to buildid/type/name/properties. WhenresourceIdis actually just the bare symbolic name (e.g."vnet"), that parsing produces a malformed/incomplete mock object whose nested property values are not usable as arrays/strings by strict functions likeconcat()/map().Note that
ObjectDeploymentSymbol/ArrayDeploymentSymbolalready retain a reference to the realIResourceValueviaConfigure(resource)— it's discarded after computing the id string, which is what forces the failed_ResourceIdsround-trip in step 3.To Reproduce
other.bicepmain.bicepbicep build main.bicep --stdout(with theimportpresent) produceslanguageVersion 2.0output using the bare symbolic form:Running this through PSRule's pre-flight expansion (
Assert-PSRule/ps-rule-assert@2against the compiled template, or directly viaGetBicepParamResources) fails to resolveaddressPrefixescorrectly and can throw when the value feedsconcat()/map()elsewhere in the template.Removing the
importline removes the trigger forlanguageVersion 2.0and restores theextensionResourceId(...)-based codegen, which — while still ultimately routed through the sameMock.MockResourcefallback forexistingresources — receives a well-formed resource ID and therefore avoids the malformed mock.Expected behavior
PSRule's expansion of
existingcross-scope resources should resolve consistently regardless of whether the compiled template uses theresourceId(...)/extensionResourceId(...)form or the bare symbolic-name form used bylanguageVersion 2.0templates, so that property access (including throughconcat()/map()) does not fail or silently produce malformed values.Suggested fix direction
Rather than round-tripping symbolic names through
TemplateContext._ResourceIds(which by design never containsexistingresources),TryGetResourcecould resolve the actualIResourceValuedirectly from the matched symbol — e.g. by extendingIDeploymentSymbolwith aGetResource(int index)accessor alongside the existingGetId(int index), and havingObjectDeploymentSymbol/ArrayDeploymentSymbolreturn theIResourceValuethey already hold viaConfigure(...).Functions.Reference/Functions.Referenceswould then get the realExistingResourceValue(which already computes scope-awareId/Name) instead of falling back toMock.MockResourcewith an unparsable identifier.This looks like a contained change scoped to
Arm/Symbols/*,Arm/Deployments/TemplateContext.cs, andArm/Expressions/Functions.cs, testable with the existingArm/Deployments/Arm/Expressionsunit test suites — not a large architectural change.Environment
existingresource with cross-scope scope: compilesreference()using a bare symbolic name instead ofextensionResourceId()when anyimportstatement is present in the file bicep#20321 (closed as not-a-bicep-bug; codegen forlanguageVersion 2.0symbolic references is correct/standard ARM behavior).ps-rule-assert@2task.