diff --git a/Tests/PfbApiDriftTools.Tests.ps1 b/Tests/PfbApiDriftTools.Tests.ps1 index 601cbd1..c239be0 100644 --- a/Tests/PfbApiDriftTools.Tests.ps1 +++ b/Tests/PfbApiDriftTools.Tests.ps1 @@ -222,6 +222,53 @@ Describe 'Get-PfbParameterCoverageGaps' { $gap = $result.ParameterGaps | Where-Object { $_.Endpoint -eq 'GET /widgets' } $gap.MissingParameters | Should -Be @('apple', 'mango', 'zebra') } + + Context 'a cmdlet contributing zero inventory rows is vacuously fully mapped' { + # Regression guard for the null-vs-empty conflation: the per-cmdlet lookup is a + # Group-Object -AsHashTable, which returns $null for an absent key, and @($null) is + # a ONE-element array whose element is $null -- so the surface loop used to run once + # with $row = $null, read $null.Surface -ne 'Typed' as true, and discard the whole + # endpoint into NotVerified. The real shape is the -Attributes-only write cmdlet + # (Update-PfbArray, Update-PfbPasswordPolicy, Update-PfbSupport): every parameter it + # declares is -Array or -Attributes, both inventory-exempt, so the inventory holds no + # row for it at all -- nothing contradicts full mapping. + BeforeAll { + $script:attributesOnlyCapMap = [PSCustomObject]@{ + endpoints = [PSCustomObject]@{ + 'PATCH /widgets' = [PSCustomObject]@{ + minVersion = '2.0' + parameters = [PSCustomObject]@{} + bodyProperties = [PSCustomObject]@{ banner = '2.0'; ntp_servers = '2.0' } + } + } + } + $script:attributesOnlyEndpoints = @( + [PSCustomObject]@{ Key = 'PATCH /widgets'; Method = 'PATCH'; Endpoint = '/widgets'; Resolved = $true; Cmdlet = 'Update-PfbFixtureWidget'; File = 'x' } + ) + } + + It 'reports gaps rather than NotVerified when the inventory holds no row for the cmdlet' { + # Inventory deliberately non-empty but for a DIFFERENT cmdlet, so the hashtable + # lookup for Update-PfbFixtureWidget misses and yields $null. + $result = Get-PfbParameterCoverageGaps -CapabilityMap $attributesOnlyCapMap -CmdletInventory $fullyMappedInventory -CalledEndpoints $attributesOnlyEndpoints + $result.NotVerified | Where-Object { $_.Endpoint -eq 'PATCH /widgets' } | Should -BeNullOrEmpty + $gap = $result.ParameterGaps | Where-Object { $_.Endpoint -eq 'PATCH /widgets' } + $gap | Should -Not -BeNullOrEmpty + $gap.MissingParameters | Should -Be @('banner', 'ntp_servers') + } + + It 'still reports NotVerified when a second cmdlet on the same endpoint has an unresolved surface' { + # The vacuous-mapping rule must not become a blanket amnesty: a real + # AttributesOnly/TypedUnresolved row anywhere on the endpoint still gates it. + $twoCmdletEndpoints = @( + $attributesOnlyEndpoints[0] + [PSCustomObject]@{ Key = 'PATCH /widgets'; Method = 'PATCH'; Endpoint = '/widgets'; Resolved = $true; Cmdlet = 'Get-PfbFixtureWidget'; File = 'x' } + ) + $result = Get-PfbParameterCoverageGaps -CapabilityMap $attributesOnlyCapMap -CmdletInventory $notFullyMappedInventory -CalledEndpoints $twoCmdletEndpoints + $result.ParameterGaps | Where-Object { $_.Endpoint -eq 'PATCH /widgets' } | Should -BeNullOrEmpty + ($result.NotVerified | Where-Object { $_.Endpoint -eq 'PATCH /widgets' }).Reason | Should -Be 'has attributes/unresolved surface' + } + } } Describe 'Get-PfbValidateSetDrift' { diff --git a/Tests/PfbCmdletParamTools.Tests.ps1 b/Tests/PfbCmdletParamTools.Tests.ps1 index ec64d9a..bc0272c 100644 --- a/Tests/PfbCmdletParamTools.Tests.ps1 +++ b/Tests/PfbCmdletParamTools.Tests.ps1 @@ -216,6 +216,197 @@ function Get-PfbFixtureSharedAccumulator { } '@ + # --- Add-PfbCommonQueryParams (issue #32/#33) fixtures ----------------------------- + # Real Get-PfbBucketPerformance shape: -Name/-Id handed straight to the shared helper, + # and -Filter/-Sort/-Limit/-TotalOnly reaching the wire only via $PSBoundParameters. + Set-Content -Path (Join-Path $fixtureDir 'Get-PfbFixtureHelperDirect.ps1') -Value @' +function Get-PfbFixtureHelperDirect { + [CmdletBinding()] + param( + [Parameter()] [PSCustomObject]$Array, + [Parameter()] [string[]]$Name, + [Parameter()] [string[]]$Id, + [Parameter()] [string]$Filter, + [Parameter()] [string]$Sort, + [Parameter()] [int]$Limit, + [Parameter()] [switch]$TotalOnly + ) + $queryParams = @{} + Add-PfbCommonQueryParams -Into $queryParams -BoundParameters $PSBoundParameters -Names $Name -Ids $Id + Invoke-PfbApiRequest -Array $Array -Method GET -Endpoint 'helper-direct' -QueryParams $queryParams -AutoPaginate +} +'@ + + # Real Get-PfbFileSystem shape: the helper receives the `process`-block accumulators, + # not the parameters -- so resolution must go parameter -> accumulator -> helper argument. + Set-Content -Path (Join-Path $fixtureDir 'Get-PfbFixtureHelperAccumulator.ps1') -Value @' +function Get-PfbFixtureHelperAccumulator { + [CmdletBinding()] + param( + [Parameter()] [PSCustomObject]$Array, + [Parameter(ValueFromPipeline)] [string[]]$Name, + [Parameter()] [string[]]$Id + ) + begin { + $allNames = [System.Collections.Generic.List[string]]::new() + $allIds = [System.Collections.Generic.List[string]]::new() + } + process { + if ($Name) { foreach ($n in $Name) { $allNames.Add($n) } } + if ($Id) { foreach ($i in $Id) { $allIds.Add($i) } } + } + end { + $queryParams = @{} + Add-PfbCommonQueryParams -Into $queryParams -BoundParameters $PSBoundParameters -Names $allNames -Ids $allIds + Invoke-PfbApiRequest -Array $Array -Method GET -Endpoint 'helper-accumulator' -QueryParams $queryParams -AutoPaginate + } +} +'@ + + # Real Get-PfbObjectStoreAccessPolicyRule shape: the genuinely MIXED case. -Name goes + # through the helper's generic 'names', while -PolicyName deliberately kept its own + # explicit non-generic 'policy_names' line after the helper call (issue #32's design). + # Both must resolve, and the explicit line must not be shadowed by the helper. + Set-Content -Path (Join-Path $fixtureDir 'Get-PfbFixtureHelperMixed.ps1') -Value @' +function Get-PfbFixtureHelperMixed { + [CmdletBinding()] + param( + [Parameter()] [PSCustomObject]$Array, + [Parameter(ValueFromPipeline)] [string[]]$PolicyName, + [Parameter()] [string[]]$Name, + [Parameter()] [string]$Filter + ) + begin { + $allPolicyNames = [System.Collections.Generic.List[string]]::new() + $allNames = [System.Collections.Generic.List[string]]::new() + } + process { + if ($PolicyName) { foreach ($n in $PolicyName) { $allPolicyNames.Add($n) } } + if ($Name) { foreach ($n in $Name) { $allNames.Add($n) } } + } + end { + $queryParams = @{} + Add-PfbCommonQueryParams -Into $queryParams -BoundParameters $PSBoundParameters -Names $allNames + if ($allPolicyNames.Count -gt 0) { $queryParams['policy_names'] = $allPolicyNames -join ',' } + Invoke-PfbApiRequest -Array $Array -Method GET -Endpoint 'helper-mixed' -QueryParams $queryParams -AutoPaginate + } +} +'@ + + # Negative guard: the ByParameterName half of the mapping is only true because the helper + # reads the CALLER's $PSBoundParameters. A call that does not forward it cannot be assumed + # to map -Filter, so -Filter must stay unresolved rather than be credited to 'filter'. + Set-Content -Path (Join-Path $fixtureDir 'Get-PfbFixtureHelperNoBoundParams.ps1') -Value @' +function Get-PfbFixtureHelperNoBoundParams { + [CmdletBinding()] + param( + [Parameter()] [PSCustomObject]$Array, + [Parameter()] [string]$Filter + ) + $queryParams = @{} + $someOtherDictionary = @{} + Add-PfbCommonQueryParams -Into $queryParams -BoundParameters $someOtherDictionary + Invoke-PfbApiRequest -Array $Array -Method GET -Endpoint 'helper-no-bound' -QueryParams $queryParams -AutoPaginate +} +'@ + + # --- Hashtable-literal-initializer fixtures --------------------------------------- + # Real New-PfbApiClient/New-PfbObjectStoreAccount shape: the wire key exists ONLY inside + # a hashtable literal, never as a later $queryParams['names'] = ... index assignment. + Set-Content -Path (Join-Path $fixtureDir 'New-PfbFixtureLiteralOnly.ps1') -Value @' +function New-PfbFixtureLiteralOnly { + [CmdletBinding()] + param( + [Parameter(Position = 0)] [string]$Name, + [Parameter()] [PSCustomObject]$Array + ) + $queryParams = @{ 'names' = $Name } + Invoke-PfbApiRequest -Array $Array -Method POST -Endpoint 'literal-only' -QueryParams $queryParams +} +'@ + + # Real New-PfbBucket/New-PfbFileSystem shape: a literal initializer AND later index + # assignments into $body coexist in one cmdlet, against two different target variables. + # Both key sets must resolve, each to its own target variable. + Set-Content -Path (Join-Path $fixtureDir 'New-PfbFixtureLiteralMixed.ps1') -Value @' +function New-PfbFixtureLiteralMixed { + [CmdletBinding()] + param( + [Parameter(Position = 0)] [string]$Name, + [Parameter()] [string]$NewName, + [Parameter()] [string]$Hostname, + [Parameter()] [PSCustomObject]$Array + ) + $queryParams = @{ 'names' = $Name } + $body = @{ 'name' = $NewName } + if ($Hostname) { $body['host_name'] = $Hostname } + Invoke-PfbApiRequest -Array $Array -Method PATCH -Endpoint 'literal-mixed' -Body $body -QueryParams $queryParams +} +'@ + + # Real New-PfbWorkload/Remove-PfbWorkloadTag shape: the literal's value is an EXPRESSION + # wrapping the parameter (@(...) or -join), not a bare variable reference. + Set-Content -Path (Join-Path $fixtureDir 'New-PfbFixtureLiteralWrapped.ps1') -Value @' +function New-PfbFixtureLiteralWrapped { + [CmdletBinding()] + param( + [Parameter()] [string[]]$Name, + [Parameter()] [string[]]$Key, + [Parameter()] [PSCustomObject]$Array + ) + $queryParams = @{ 'names' = @($Name); 'keys' = $Key -join ',' } + Invoke-PfbApiRequest -Array $Array -Method POST -Endpoint 'literal-wrapped' -QueryParams $queryParams +} +'@ + + # Real New-PfbQuotaGroup shape: a NESTED single-key sub-object inside a hashtable-literal + # initializer. The wire field is the OUTER key ('group') -- never the inner 'name', which + # would both mis-name the field and collide with every other sub-object's 'name'. + Set-Content -Path (Join-Path $fixtureDir 'New-PfbFixtureNestedLiteral.ps1') -Value @' +function New-PfbFixtureNestedLiteral { + [CmdletBinding()] + param( + [Parameter()] [string]$GroupName, + [Parameter()] [int64]$Quota, + [Parameter()] [PSCustomObject]$Array + ) + $body = @{ group = @{ name = $GroupName }; quota = $Quota } + Invoke-PfbApiRequest -Array $Array -Method POST -Endpoint 'nested-literal' -Body $body +} +'@ + + # Real New-PfbBucket/New-PfbFileSystem/New-PfbServer shape: a nested single-key reference + # object keyed in by INDEX assignment. Covers, in order: the plain reference object; two + # parameters legitimately resolving to the SAME outer key ('account' addressed by name and + # by id -- correct, not a collision to suppress); a non-'name' inner key (real + # New-PfbFileSystem: eradication_config = @{ eradication_mode = ... }); a plain sibling key + # that must keep resolving directly; a MULTI-key sub-object, whose per-field ownership + # cannot be attributed to one parameter; and two levels of nesting, which is not descended. + Set-Content -Path (Join-Path $fixtureDir 'New-PfbFixtureNestedReference.ps1') -Value @' +function New-PfbFixtureNestedReference { + [CmdletBinding()] + param( + [Parameter()] [string]$Account, + [Parameter()] [string]$AccountId, + [Parameter()] [string]$EradicationMode, + [Parameter()] [string]$Versioning, + [Parameter()] [string]$SourceName, + [Parameter()] [string]$SourceId, + [Parameter()] [string]$Deep, + [Parameter()] [PSCustomObject]$Array + ) + $body = @{} + if ($Account) { $body['account'] = @{ name = $Account } } + if ($AccountId) { $body['account'] = @{ id = $AccountId } } + if ($EradicationMode) { $body['eradication_config'] = @{ eradication_mode = $EradicationMode } } + if ($Versioning) { $body['versioning'] = $Versioning } + if ($SourceName -or $SourceId) { $body['source'] = @{ name = $SourceName; id = $SourceId } } + if ($Deep) { $body['outer'] = @{ middle = @{ name = $Deep } } } + Invoke-PfbApiRequest -Array $Array -Method POST -Endpoint 'nested-reference' -Body $body +} +'@ + + $script:helperPath = Join-Path $repoRoot 'Private/Add-PfbCommonQueryParams.ps1' $script:inventory = Get-PfbCmdletParameterInventory -PublicDirectory $fixtureDir } @@ -358,6 +549,303 @@ Describe 'Endpoint/Method resolution (Get-PfbEndpointForVariable, via the invent } } +Describe 'Add-PfbCommonQueryParams awareness (issue #32/#33)' { + It 'resolves -Name/-Id handed straight to the helper as names/ids' { + $name = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperDirect' -and $_.Parameter -eq 'Name' } + $name.WireName | Should -Be 'names' + $name.Surface | Should -Be 'Typed' + $id = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperDirect' -and $_.Parameter -eq 'Id' } + $id.WireName | Should -Be 'ids' + $id.Surface | Should -Be 'Typed' + } + + It 'resolves -Filter/-Sort/-Limit/-TotalOnly by PARAMETER NAME, since the helper reads them from $PSBoundParameters' -ForEach @( + @{ Parameter = 'Filter'; WireName = 'filter' } + @{ Parameter = 'Sort'; WireName = 'sort' } + @{ Parameter = 'Limit'; WireName = 'limit' } + @{ Parameter = 'TotalOnly'; WireName = 'total_only' } + ) { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperDirect' -and $_.Parameter -eq $Parameter } + $rec.WireName | Should -Be $WireName + $rec.Surface | Should -Be 'Typed' + } + + It 'still resolves the -Into variable to its Invoke-PfbApiRequest endpoint' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperDirect' -and $_.Parameter -eq 'Filter' } + $rec.Endpoint | Should -Be 'helper-direct' + $rec.Method | Should -Be 'GET' + } + + It 'resolves the accumulator pattern (-Names $allNames where $allNames builds from $Name)' { + $name = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperAccumulator' -and $_.Parameter -eq 'Name' } + $name.WireName | Should -Be 'names' + $name.Surface | Should -Be 'Typed' + $name.Endpoint | Should -Be 'helper-accumulator' + $id = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperAccumulator' -and $_.Parameter -eq 'Id' } + $id.WireName | Should -Be 'ids' + $id.Surface | Should -Be 'Typed' + } + + It 'resolves a mixed cmdlet: the helper-routed generic param AND its own explicit non-generic line' { + $policy = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperMixed' -and $_.Parameter -eq 'PolicyName' } + $policy.WireName | Should -Be 'policy_names' + $policy.Surface | Should -Be 'Typed' + $name = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperMixed' -and $_.Parameter -eq 'Name' } + $name.WireName | Should -Be 'names' + $name.Surface | Should -Be 'Typed' + $filter = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperMixed' -and $_.Parameter -eq 'Filter' } + $filter.WireName | Should -Be 'filter' + } + + It 'does NOT credit -Filter when the call does not forward $PSBoundParameters' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'Get-PfbFixtureHelperNoBoundParams' -and $_.Parameter -eq 'Filter' } + $rec.WireName | Should -BeNullOrEmpty + $rec.Surface | Should -Be 'TypedUnresolved' + } + + It 'returns $null from Get-PfbCommonQueryParamHelperWireName when -Into is not a plain variable' { + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string]$Filter) Add-PfbCommonQueryParams -Into @{} -BoundParameters $PSBoundParameters }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + Get-PfbCommonQueryParamHelperWireName -FunctionAst $funcAst -ParameterName 'Filter' | Should -BeNullOrEmpty + } + + It 'returns $null from Get-PfbCommonQueryParamHelperWireName when the function never calls the helper' { + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string]$Filter) $queryParams = @{} }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + Get-PfbCommonQueryParamHelperWireName -FunctionAst $funcAst -ParameterName 'Filter' | Should -BeNullOrEmpty + } +} + +Describe 'Get-PfbCommonQueryParamMap stays in sync with Private/Add-PfbCommonQueryParams.ps1' { + # Guards the one hazard of hardcoding the mapping: the helper gains, loses, or renames a + # key and this tools/ mirror silently keeps reporting the old contract. Derives the truth + # from the helper's own AST and compares, so drift fails the build instead of quietly + # dropping endpoints back out of gap analysis. + BeforeAll { + $script:derivedByParameterName = @{} + $script:derivedByHelperArgument = @{} + $script:derivedHelperName = $null + + $tokens = $null; $errs = $null + $helperAst = [System.Management.Automation.Language.Parser]::ParseFile($helperPath, [ref]$tokens, [ref]$errs) + $script:derivedHelperName = ($helperAst.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | + Select-Object -First 1).Name + + $assignments = @($helperAst.FindAll({ + param($n) + $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $n.Left -is [System.Management.Automation.Language.IndexExpressionAst] + }, $true)) | Where-Object { + $t = $_.Left.Target -as [System.Management.Automation.Language.VariableExpressionAst] + $t -and $t.VariablePath.UserPath -eq 'Into' + } + + foreach ($assign in $assignments) { + $wireKey = ($assign.Left.Index -as [System.Management.Automation.Language.StringConstantExpressionAst]).Value + + # Every assignment in the helper sits inside a one-clause `if`; the clause's + # condition is what says WHERE the value came from. + $node = $assign.Parent + while ($node -and $node -isnot [System.Management.Automation.Language.IfStatementAst]) { $node = $node.Parent } + $condition = '' + if ($node) { + foreach ($clause in $node.Clauses) { + if (@($clause.Item2.FindAll({ param($n) $n -eq $assign }, $true)).Count -gt 0) { + $condition = $clause.Item1.Extent.Text.Trim() + } + } + } + + if ($condition -match '^\$BoundParameters\.ContainsKey\((?:''|")(\w+)(?:''|")\)$') { + $derivedByParameterName[$Matches[1]] = $wireKey + } + elseif ($condition -match '^\$(\w+)$') { + $derivedByHelperArgument[$Matches[1]] = $wireKey + } + else { + throw "Unrecognized guard shape around `$Into['$wireKey'] in $helperPath : '$condition'. Get-PfbCommonQueryParamMap's detection rules may no longer describe this helper." + } + } + } + + It 'reads a non-empty mapping out of the real helper (guards against a vacuous pass)' { + $derivedByParameterName.Count | Should -BeGreaterThan 0 + $derivedByHelperArgument.Count | Should -BeGreaterThan 0 + } + + It 'names the same helper the real file defines' { + (Get-PfbCommonQueryParamMap).HelperName | Should -Be $derivedHelperName + } + + It 'mirrors the helper $PSBoundParameters-driven keys exactly' { + $map = Get-PfbCommonQueryParamMap + @($map.ByParameterName.Keys) | Sort-Object | Should -Be (@($derivedByParameterName.Keys) | Sort-Object) + foreach ($k in $derivedByParameterName.Keys) { + $map.ByParameterName[$k] | Should -Be $derivedByParameterName[$k] -Because "the helper assigns `$Into['$($derivedByParameterName[$k])'] for -$k" + } + } + + It 'mirrors the helper own-argument keys exactly' { + $map = Get-PfbCommonQueryParamMap + @($map.ByHelperArgument.Keys) | Sort-Object | Should -Be (@($derivedByHelperArgument.Keys) | Sort-Object) + foreach ($k in $derivedByHelperArgument.Keys) { + $map.ByHelperArgument[$k] | Should -Be $derivedByHelperArgument[$k] -Because "the helper assigns `$Into['$($derivedByHelperArgument[$k])'] from its own -$k argument" + } + } +} + +Describe 'Hashtable-literal-initializer awareness' { + It 'resolves a wire key that exists only inside a $queryParams = @{ ... } literal' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureLiteralOnly' -and $_.Parameter -eq 'Name' } + $rec.WireName | Should -Be 'names' + $rec.Surface | Should -Be 'Typed' + $rec.Endpoint | Should -Be 'literal-only' + $rec.Method | Should -Be 'POST' + } + + It 'resolves a literal assigned to $body, and reports body (not queryParams) as the target' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureLiteralMixed' -and $_.Parameter -eq 'NewName' } + $rec.WireName | Should -Be 'name' + $rec.Surface | Should -Be 'Typed' + $rec.Endpoint | Should -Be 'literal-mixed' + } + + It 'resolves BOTH halves of a cmdlet that uses a literal initializer and index assignments' { + $fromLiteral = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureLiteralMixed' -and $_.Parameter -eq 'Name' } + $fromLiteral.WireName | Should -Be 'names' + $fromIndex = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureLiteralMixed' -and $_.Parameter -eq 'Hostname' } + $fromIndex.WireName | Should -Be 'host_name' + @($fromLiteral, $fromIndex).Surface | Should -Be @('Typed', 'Typed') + } + + It 'resolves a literal value that wraps the parameter in an expression rather than referencing it bare' -ForEach @( + @{ Parameter = 'Name'; WireName = 'names' } # @($Name) + @{ Parameter = 'Key'; WireName = 'keys' } # $Key -join ',' + ) { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureLiteralWrapped' -and $_.Parameter -eq $Parameter } + $rec.WireName | Should -Be $WireName + $rec.Surface | Should -Be 'Typed' + } + + It 'credits a nested sub-object literal to its OUTER key, never the inner one' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedLiteral' -and $_.Parameter -eq 'GroupName' } + $rec.WireName | Should -Be 'group' + $rec.WireName | Should -Not -Be 'name' + $rec.Surface | Should -Be 'Typed' + } + + It 'still resolves a sibling top-level key in the same nested literal' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedLiteral' -and $_.Parameter -eq 'Quota' } + $rec.WireName | Should -Be 'quota' + $rec.Surface | Should -Be 'Typed' + } + + It 'does NOT match a literal value that merely mentions the parameter inside a pipeline transform' { + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string[]]$Servers) $body = @{ attached_servers = @($Servers | ForEach-Object { @{ name = $_ } }) } }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + Get-PfbHashtableLiteralWireNameForParameter -FunctionAst $funcAst -ParameterName 'Servers' | Should -BeNullOrEmpty + } + + It 'ignores a hashtable literal assigned to a variable that is neither body nor queryParams' { + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string]$Name) $somethingElse = @{ names = $Name } }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + Get-PfbHashtableLiteralWireNameForParameter -FunctionAst $funcAst -ParameterName 'Name' | Should -BeNullOrEmpty + } +} + +Describe 'Nested single-key reference-object awareness' { + # The API models "point this resource at that one" as {"account": {"name": "acct1"}}, and + # the capability map records TOP-LEVEL body properties only -- there is no `account.name` + # field in it -- so the wire name such a parameter covers is the OUTER key. + It 'resolves an index-assigned reference object to its outer key' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedReference' -and $_.Parameter -eq 'Account' } + $rec.WireName | Should -Be 'account' + $rec.Surface | Should -Be 'Typed' + $rec.Endpoint | Should -Be 'nested-reference' + $rec.Method | Should -Be 'POST' + } + + It 'lets TWO parameters resolve to the same outer key (addressing one field by name or by id is correct, not a collision)' { + $byName = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedReference' -and $_.Parameter -eq 'Account' } + $byId = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedReference' -and $_.Parameter -eq 'AccountId' } + $byName.WireName | Should -Be 'account' + $byId.WireName | Should -Be 'account' + $byId.Surface | Should -Be 'Typed' + } + + It 'does not require the inner key to be "name" (real New-PfbFileSystem eradication_config shape)' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedReference' -and $_.Parameter -eq 'EradicationMode' } + $rec.WireName | Should -Be 'eradication_config' + $rec.Surface | Should -Be 'Typed' + } + + It 'still resolves a plain sibling key in the same cmdlet directly' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedReference' -and $_.Parameter -eq 'Versioning' } + $rec.WireName | Should -Be 'versioning' + $rec.Surface | Should -Be 'Typed' + } + + It 'refuses a MULTI-key sub-object, whose per-field ownership cannot be attributed to one parameter' -ForEach @( + @{ Parameter = 'SourceName' } + @{ Parameter = 'SourceId' } + ) { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedReference' -and $_.Parameter -eq $Parameter } + $rec.WireName | Should -BeNullOrEmpty + $rec.Surface | Should -Be 'TypedUnresolved' + } + + It 'descends exactly one level -- a doubly-nested sub-object stays unresolved' { + $rec = $inventory | Where-Object { $_.Cmdlet -eq 'New-PfbFixtureNestedReference' -and $_.Parameter -eq 'Deep' } + $rec.WireName | Should -BeNullOrEmpty + $rec.Surface | Should -Be 'TypedUnresolved' + } + + It 'lets a DIRECT assignment win over a nested one for the same parameter, whatever the source order' { + # The ordering guarantee that makes this change strictly additive: nested resolution + # runs as its own pass after both direct-assignment passes, so it can only ever turn + # an unresolved parameter Typed -- never rename an already-resolved wire name. + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string]$Name) $body = @{}; $body["owner"] = @{ name = $Name }; $body["name"] = $Name }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + (Get-PfbWireNameForParameter -FunctionAst $funcAst -ParameterName 'Name').WireName | Should -Be 'name' + } + + It 'ignores a reference object keyed into an intermediate variable that is neither body nor queryParams' { + # Real New-PfbFileSystem $nfsBody/$smbBody: not traceable to an Invoke-PfbApiRequest + # call, so there is nothing to attribute the wire name to. + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string]$Policy) $nfsBody = @{}; $nfsBody["export_policy"] = @{ name = $Policy } }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + Get-PfbNestedReferenceWireNameForParameter -FunctionAst $funcAst -ParameterName 'Policy' | Should -BeNullOrEmpty + } + + It 'refuses a nested value produced by a pipeline transform rather than referencing the parameter' { + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string[]]$Servers) $body = @{}; $body["attached_servers"] = @($Servers | ForEach-Object { @{ name = $_ } }) }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + Get-PfbNestedReferenceWireNameForParameter -FunctionAst $funcAst -ParameterName 'Servers' | Should -BeNullOrEmpty + } + + It 'refuses a non-literal outer key' { + $tokens = $null; $errs = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + 'function Test-Fixture { param([string]$Name, [string]$Key) $body = @{}; $body[$Key] = @{ name = $Name } }', [ref]$tokens, [ref]$errs) + $funcAst = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + Get-PfbNestedReferenceWireNameForParameter -FunctionAst $funcAst -ParameterName 'Name' | Should -BeNullOrEmpty + } +} + Describe 'Find-PfbAccumulatorVariable' { It 'returns $null when the parameter has no foreach loop over it at all' { $tokens = $null; $errs = $null diff --git a/tools/lib/PfbApiDriftTools.ps1 b/tools/lib/PfbApiDriftTools.ps1 index bc0dab6..1df435f 100644 --- a/tools/lib/PfbApiDriftTools.ps1 +++ b/tools/lib/PfbApiDriftTools.ps1 @@ -243,7 +243,18 @@ function Get-PfbParameterCoverageGaps { $fullyMapped = $true $exposedWireNames = [System.Collections.Generic.HashSet[string]]::new() foreach ($cmdletName in $cmdlets) { - $rows = @($inventoryByCmdlet[$cmdletName]) + # The Where-Object is load-bearing, not defensive noise: Group-Object + # -AsHashTable yields $null for an absent key, and @($null) is a ONE-element + # array whose single element is $null -- not the empty array it reads as. The + # loop below therefore used to run once with $row = $null, evaluate + # $null.Surface -ne 'Typed' as TRUE, and discard the endpoint into $notVerified. + # A cmdlet contributes zero inventory rows when every parameter it declares is + # inventory-exempt (-Array/-Attributes are plumbing, never inventoried -- see + # Get-PfbCmdletParameterInventory), which is exactly the shape of the + # -Attributes-only write cmdlets (Update-PfbArray, Update-PfbPasswordPolicy, + # Update-PfbSupport, ...). No rows means nothing contradicts full mapping, so + # such a cmdlet is VACUOUSLY fully mapped, not unmappable. + $rows = @($inventoryByCmdlet[$cmdletName] | Where-Object { $null -ne $_ }) foreach ($row in $rows) { if ($row.Surface -ne 'Typed') { $fullyMapped = $false; continue } if ($row.WireName) { [void]$exposedWireNames.Add($row.WireName) } diff --git a/tools/lib/PfbCmdletParamTools.ps1 b/tools/lib/PfbCmdletParamTools.ps1 index e327f58..7385302 100644 --- a/tools/lib/PfbCmdletParamTools.ps1 +++ b/tools/lib/PfbCmdletParamTools.ps1 @@ -14,6 +14,35 @@ if ($Param) { $body['wire_name'] = @($Param) } # array parameters if ($queryParams.ContainsKey(...)) ... # not matched, no enum data anyway $queryParams['wire_name'] = $Param + $queryParams = @{ 'wire_name' = $Param } # hashtable-literal initializer + $body['wire_name'] = @{ name = $Param } # nested single-key reference + # object -- OUTER key wins + + ...plus one indirect pattern, where a shared private helper does the assignment for the + cmdlet and the cmdlet body therefore contains no literal key at all: + + Add-PfbCommonQueryParams -Into $queryParams -BoundParameters $PSBoundParameters ` + -Names $allNames -Ids $allIds + + That helper (Private/Add-PfbCommonQueryParams.ps1, introduced by issue #32/PR #49 and + extended across ~196 Get-Pfb* cmdlets by issue #33/PR #51) is mirrored by + Get-PfbCommonQueryParamMap. Without it, every migrated cmdlet's -Filter/-Sort/-Limit/ + -TotalOnly/-Name/-Id resolved no wire name, which cascaded into + Get-PfbParameterCoverageGaps (tools/lib/PfbApiDriftTools.ps1) demoting the cmdlet's whole + endpoint into its notVerified bucket -- i.e. a pure mechanical refactor made hundreds of + endpoints silently drop out of gap analysis, and the report's headline missing-field + count fell as if the gaps had been fixed. The hashtable-literal initializer above + (Get-PfbHashtableLiteralWireNameForParameter) was invisible for the same reason: only the + IndexExpressionAst form was ever recognized, so ~99 (cmdlet, parameter) pairs across ~82 + mostly-New-Pfb* cmdlets -- New-PfbApiClient's own -Name among them -- were reported as + AttributesOnly/TypedUnresolved despite demonstrably reaching the wire. + + The nested single-key reference object (Get-PfbNestedReferenceWireNameForParameter) is the + same class of blindness, one level deeper: the API models "point this resource at that + one" as `{"account": {"name": "acct1"}}`, and the capability map records TOP-LEVEL body + properties only -- there is no `account.name` in it -- so the field such a parameter + covers is the OUTER key. Leaving it unresolved cost 11 (cmdlet, parameter) pairs across 8 + cmdlets, and via the notVerified gate their whole endpoints. A parameter is classified into exactly one Surface: - 'Typed': a wire name was resolved via a direct (optionally @()-wrapped) assignment. @@ -75,6 +104,232 @@ function Test-PfbAssignmentGuardedBySwitch { return $false } +function Resolve-PfbSingleExpression { + <# + .SYNOPSIS + Unwraps the StatementAst layers PowerShell's parser puts around a single-expression + right-hand side, returning the innermost ExpressionAst (or the input unchanged when + it is not that shape). + .DESCRIPTION + Both AssignmentStatementAst.Right and a HashtableAst key/value pair's Item2 are typed + StatementAst, and the parser wraps a bare expression in a CommandExpressionAst -- + sometimes itself inside a single-element PipelineAst -- rather than exposing the + BinaryExpressionAst/HashtableAst/VariableExpressionAst directly. Casting without + unwrapping silently yields $null, which reads as "pattern not matched" instead of + "wrong layer". Both layers are peeled here, in either order, so callers do not each + re-derive the same lore (previously duplicated inline; see also + Find-PfbAccumulatorVariable, which peels a loop condition the same way). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.Ast]$Ast + ) + + $node = $Ast + for ($i = 0; $i -lt 3; $i++) { + if ($node -is [System.Management.Automation.Language.PipelineAst]) { + if ($node.PipelineElements.Count -ne 1) { return $node } + $node = $node.PipelineElements[0] + continue + } + if ($node -is [System.Management.Automation.Language.CommandExpressionAst]) { + $node = $node.Expression + continue + } + break + } + return $node +} + +function Test-PfbWireValueIsParameter { + <# + .SYNOPSIS + True if -ValueAst -- the value half of a wire-key assignment, either + `$body['k'] = ` or the `@{ 'k' = }` literal form -- hands the named + parameter's own value to the wire in one of the exact shapes this repo uses. + .DESCRIPTION + Deliberately shape-exact rather than "mentions the variable anywhere": over-matching + would misattribute a wire name (and, downstream, a spec value enum) to the wrong + field. Accepted: + $Param -- direct + @($Param) -- array-wrapped + $Param -join ',' -- joined into a plural query key + 'literal' -- ONLY for a [switch] whose mere presence is keyed to a + hardcoded string, and only inside an `if ($Param)` guard + Refused (correctly, per this file's "never guess" contract): anything else, e.g. + `@($Param | ForEach-Object { ... })` or `"$Param"`. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.Ast]$ValueAst, + + [Parameter(Mandatory)] + [string]$ParameterName, + + [switch]$IsSwitchParameter + ) + + $text = $ValueAst.Extent.Text.Trim() + $simple = '$' + $ParameterName + if ($text -eq $simple -or $text -eq ('@(' + $simple + ')')) { return $true } + + $expr = Resolve-PfbSingleExpression -Ast $ValueAst + + $binary = $expr -as [System.Management.Automation.Language.BinaryExpressionAst] + if ($binary -and $binary.Operator -eq [System.Management.Automation.Language.TokenKind]::Join) { + $joinLeft = $binary.Left -as [System.Management.Automation.Language.VariableExpressionAst] + if ($joinLeft -and $joinLeft.VariablePath.UserPath -eq $ParameterName) { return $true } + } + + if ($IsSwitchParameter -and $expr -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + if (Test-PfbAssignmentGuardedBySwitch -Assignment $ValueAst -ParameterName $ParameterName) { return $true } + } + + return $false +} + +function Get-PfbCommonQueryParamMap { + <# + .SYNOPSIS + The wire-name mapping that Private/Add-PfbCommonQueryParams.ps1 performs on a + calling cmdlet's behalf, so a parameter routed through that shared helper still + resolves a wire name even though the calling cmdlet contains no literal + `$queryParams['...'] = $Param` line of its own. + .DESCRIPTION + HARDCODED MIRROR of Private/Add-PfbCommonQueryParams.ps1 (issue #32/PR #49 + centralized -Filter/-Sort/-Limit/-TotalOnly/-Names/-Ids there; issue #33/PR #51 + migrated the rest). It is deliberately a copy rather than derived at runtime: this + tools/ library parses Public/ as text and must not depend on the module being + importable. Tests/PfbCmdletParamTools.Tests.ps1 asserts this table still matches + the helper's own AST assignments, so the two cannot silently drift. + + Two different detection rules, because the helper learns its inputs two different ways: + + ByParameterName -- the helper reads these straight out of the caller's + $PSBoundParameters (`if ($BoundParameters.ContainsKey('Filter')) {...}`), so the + caller's own param() name is the ONLY signal available. Only trusted when the + call site actually forwards $PSBoundParameters. + ByHelperArgument -- the helper takes these as its own parameters (-Names/-Ids), so + the signal is whichever variable the call site passes to that argument. That is + usually a `process`-block accumulator ($allNames), not the parameter itself -- + handled for free by Get-PfbCmdletParameterInventory's existing + Find-PfbAccumulatorVariable retry. + + NOT included: the non-generic keys (file_system_names, policy_names, role_names, + member_names, ...). Per issue #32's design those cmdlets deliberately kept their own + explicit `$queryParams[...] = ...` lines after the helper call and were NOT routed + through -Names/-Ids, so the literal-assignment resolver still handles them -- and + must keep doing so, which is why the helper fallback runs only after it. + .OUTPUTS + [PSCustomObject]@{ HelperName; ByParameterName; ByHelperArgument } + #> + [CmdletBinding()] + param() + + return [PSCustomObject]@{ + HelperName = 'Add-PfbCommonQueryParams' + ByParameterName = [ordered]@{ + Filter = 'filter' + Sort = 'sort' + Limit = 'limit' + TotalOnly = 'total_only' + } + ByHelperArgument = [ordered]@{ + Names = 'names' + Ids = 'ids' + } + } +} + +function Get-PfbCommonQueryParamHelperWireName { + <# + .SYNOPSIS + The Add-PfbCommonQueryParams-aware half of wire-name resolution: finds the key the + shared helper assigns on this cmdlet's behalf for -ParameterName, or $null. + .DESCRIPTION + Never guesses, matching the rest of this file: requires a literal -Into + (that variable is what Get-PfbEndpointForVariable later traces to an + Invoke-PfbApiRequest call, so without it there is nothing to attribute), requires + -BoundParameters to be literally $PSBoundParameters before trusting the + ByParameterName rule, only reads plain variable arguments, and returns $null if two + helper calls in the same function disagree on the (WireName, TargetVariable) pair. + .OUTPUTS + $null, or [PSCustomObject]@{ WireName; TargetVariable } -- same shape as + Get-PfbWireNameForParameter. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.FunctionDefinitionAst]$FunctionAst, + + [Parameter(Mandatory)] + [string]$ParameterName + ) + + $map = Get-PfbCommonQueryParamMap + $helperName = $map.HelperName + + $calls = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq $helperName + }, $true)) + if ($calls.Count -eq 0) { return $null } + + $found = [System.Collections.Generic.List[string]]::new() + + foreach ($call in $calls) { + $elements = $call.CommandElements + $intoVariable = $null + $forwardsBoundParameters = $false + $argumentVariables = @{} + + for ($i = 0; $i -lt $elements.Count; $i++) { + $el = $elements[$i] + if ($el -isnot [System.Management.Automation.Language.CommandParameterAst]) { continue } + + # `-Into:$queryParams` colon form parks the value on the CommandParameterAst + # itself; `-Into $queryParams` puts it in the NEXT element. Reading $next + # unconditionally would misread the colon form as taking the following switch. + $argExpr = if ($el.Argument) { $el.Argument } + elseif ($i + 1 -lt $elements.Count) { $elements[$i + 1] } + else { $null } + if (-not $argExpr) { continue } + $argVar = $argExpr -as [System.Management.Automation.Language.VariableExpressionAst] + + if ($el.ParameterName -eq 'Into') { + if ($argVar) { $intoVariable = $argVar.VariablePath.UserPath } + } + elseif ($el.ParameterName -eq 'BoundParameters') { + if ($argVar -and $argVar.VariablePath.UserPath -eq 'PSBoundParameters') { $forwardsBoundParameters = $true } + } + elseif ($map.ByHelperArgument.Contains($el.ParameterName)) { + if ($argVar) { $argumentVariables[$el.ParameterName] = $argVar.VariablePath.UserPath } + } + } + + if (-not $intoVariable) { continue } + + foreach ($helperArg in $map.ByHelperArgument.Keys) { + if ($argumentVariables.ContainsKey($helperArg) -and $argumentVariables[$helperArg] -eq $ParameterName) { + $found.Add("$($map.ByHelperArgument[$helperArg])|$intoVariable") + } + } + + if ($forwardsBoundParameters -and $map.ByParameterName.Contains($ParameterName)) { + $found.Add("$($map.ByParameterName[$ParameterName])|$intoVariable") + } + } + + $distinct = @($found | Select-Object -Unique) + if ($distinct.Count -ne 1) { return $null } + + $parts = $distinct[0] -split '\|', 2 + return [PSCustomObject]@{ WireName = $parts[0]; TargetVariable = $parts[1] } +} + function Get-PfbWireNameForParameter { <# .SYNOPSIS @@ -112,34 +367,179 @@ function Get-PfbWireNameForParameter { $keyExpr = $indexExpr.Index -as [System.Management.Automation.Language.StringConstantExpressionAst] if (-not $keyExpr) { continue } - $rhsText = $assign.Right.Extent.Text.Trim() - $simple = '$' + $ParameterName - $wrapped = '@(' + $simple + ')' - - $isJoinOfParameter = $false - # AssignmentStatementAst.Right is a StatementAst -- for a single-expression RHS (the - # only shape these cmdlets ever use) the parser always wraps it in a CommandExpressionAst, - # never exposing a BinaryExpressionAst directly, so unwrap one level before casting. - $rhsExpr = $assign.Right - if ($rhsExpr -is [System.Management.Automation.Language.CommandExpressionAst]) { - $rhsExpr = $rhsExpr.Expression - } - $rhsBinary = $rhsExpr -as [System.Management.Automation.Language.BinaryExpressionAst] - if ($rhsBinary -and $rhsBinary.Operator -eq [System.Management.Automation.Language.TokenKind]::Join) { - $joinLeft = $rhsBinary.Left -as [System.Management.Automation.Language.VariableExpressionAst] - if ($joinLeft -and $joinLeft.VariablePath.UserPath -eq $ParameterName) { - $isJoinOfParameter = $true + if (Test-PfbWireValueIsParameter -ValueAst $assign.Right -ParameterName $ParameterName -IsSwitchParameter:$IsSwitchParameter) { + return [PSCustomObject]@{ + WireName = $keyExpr.Value + TargetVariable = $targetVar.VariablePath.UserPath } } + } + + # Second idiom: the whole hashtable is built as a LITERAL initializer rather than keyed + # into afterwards -- `$queryParams = @{ 'names' = $Name }`, the dominant shape across + # New-Pfb*/Remove-Pfb*/Update-Pfb* (New-PfbApiClient, New-PfbAlertWatcher, + # New-PfbObjectStoreAccount, the whole Policy/*Rule family, ...). Runs after the index + # form, not instead of it: a cmdlet routinely does both (literal initializer for its + # -Name, then `$body['x'] = $X` lines), and both key sets must resolve. + $literalMatch = Get-PfbHashtableLiteralWireNameForParameter -FunctionAst $FunctionAst -ParameterName $ParameterName -IsSwitchParameter:$IsSwitchParameter + if ($literalMatch) { return $literalMatch } + + # Third idiom: a nested single-key REFERENCE OBJECT -- `$body['account'] = @{ name = + # $Account }` -- whose wire field is the OUTER key. Runs strictly after both direct + # forms above so it can only ever add a resolution, never rename one: a parameter that + # already resolved via a direct assignment returned before reaching here. + $nestedMatch = Get-PfbNestedReferenceWireNameForParameter -FunctionAst $FunctionAst -ParameterName $ParameterName -IsSwitchParameter:$IsSwitchParameter + if ($nestedMatch) { return $nestedMatch } + + # No literal assignment of any shape in this function body -- but the parameter may + # still reach the wire through the shared Private/Add-PfbCommonQueryParams.ps1 helper, + # which performs the assignment on the cmdlet's behalf (issue #32/#33). Deliberately + # LAST: a cmdlet whose Name/Id-equivalent maps to a non-generic key (policy_names, + # file_system_names, ...) kept its own explicit line after the helper call, and that + # literal must win. + return Get-PfbCommonQueryParamHelperWireName -FunctionAst $FunctionAst -ParameterName $ParameterName +} + +function Get-PfbHashtableLiteralWireNameForParameter { + <# + .SYNOPSIS + The hashtable-literal-initializer half of wire-name resolution: finds the key a + parameter is given inside `$body = @{ ... }` / `$queryParams = @{ ... }`, or $null. + .DESCRIPTION + Only TOP-LEVEL key/value pairs of a hashtable literal assigned directly to a + variable named body/queryParams are considered, and a nested sub-object's INNER key + is never treated as the wire name: in `$body = @{ group = @{ name = $GroupName } }` + (real: New-PfbQuotaGroup) the wire field is `group`, so crediting -GroupName with + `name` would both mis-name the field and collide with every other sub-object's + `name`. Resolving such a parameter to its OUTER key is a separate, deliberately + later step -- see Get-PfbNestedReferenceWireNameForParameter. Value shapes are + matched by the same Test-PfbWireValueIsParameter used by the index-assignment path, + so a pipeline transform is still refused rather than guessed at. + .OUTPUTS + $null, or [PSCustomObject]@{ WireName; TargetVariable } -- same shape as + Get-PfbWireNameForParameter. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.FunctionDefinitionAst]$FunctionAst, + + [Parameter(Mandatory)] + [string]$ParameterName, - $isSwitchLiteral = $false - if ($IsSwitchParameter -and $rhsExpr -is [System.Management.Automation.Language.StringConstantExpressionAst]) { - if (Test-PfbAssignmentGuardedBySwitch -Assignment $assign -ParameterName $ParameterName) { - $isSwitchLiteral = $true + [switch]$IsSwitchParameter + ) + + $assignments = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [System.Management.Automation.Language.VariableExpressionAst] + }, $true)) + + foreach ($assign in $assignments) { + $targetVar = $assign.Left -as [System.Management.Automation.Language.VariableExpressionAst] + if ($targetVar.VariablePath.UserPath -notin @('body', 'queryParams')) { continue } + + $hashtable = (Resolve-PfbSingleExpression -Ast $assign.Right) -as [System.Management.Automation.Language.HashtableAst] + if (-not $hashtable) { continue } + + foreach ($pair in $hashtable.KeyValuePairs) { + # KeyValuePairs entries are Tuple. Keys in this repo + # are written both quoted ('names') and bare (destroyed); StringConstantExpressionAst + # covers both and exposes the unquoted text as .Value. + $keyExpr = $pair.Item1 -as [System.Management.Automation.Language.StringConstantExpressionAst] + if (-not $keyExpr) { continue } + + if (Test-PfbWireValueIsParameter -ValueAst $pair.Item2 -ParameterName $ParameterName -IsSwitchParameter:$IsSwitchParameter) { + return [PSCustomObject]@{ + WireName = $keyExpr.Value + TargetVariable = $targetVar.VariablePath.UserPath + } } } + } - if ($rhsText -eq $simple -or $rhsText -eq $wrapped -or $isJoinOfParameter -or $isSwitchLiteral) { + return $null +} + +function Get-PfbNestedReferenceWireNameForParameter { + <# + .SYNOPSIS + The nested-single-key-reference-object half of wire-name resolution: finds the OUTER + key of `$body['account'] = @{ name = $Account }` / `$body = @{ account = @{ name = + $Account } }`, or $null. + .DESCRIPTION + The REST API models a reference to another resource as a single-key sub-object + (`{"account": {"name": "acct1"}}`), and the capability map records TOP-LEVEL body + properties only -- there is no `account.name` field in it. So the wire name a + parameter feeding such a sub-object exposes is the outer key (`account`), and any + attempt to credit it with the inner key would name a field that does not exist. + + Two parameters legitimately resolving to the SAME outer key is therefore correct, + not a collision to suppress: `-Account`/`-AccountId` both address the one `account` + field, and the endpoint's gap analysis only ever asks whether `account` is covered. + + Never guesses, matching the rest of this file: + - the target variable must be body/queryParams (an intermediate like + New-PfbFileSystem's $nfsBody is not traceable to an Invoke-PfbApiRequest call); + - the outer key must be a literal string constant; + - the nested hashtable must have EXACTLY ONE key/value pair, itself string-keyed + -- a multi-key sub-object is a composite whose per-field ownership cannot be + attributed to one parameter; + - only ONE level of nesting is descended; + - the innermost value must satisfy the same Test-PfbWireValueIsParameter used by + both direct paths, so a pipeline transform (New-PfbNetworkInterface's + `@($AttachedServers | ForEach-Object { @{ name = $_ } })`) is still refused. + + Deliberately invoked LAST of the three literal forms by + Get-PfbWireNameForParameter, after both direct-assignment resolvers: it can then + only ever turn an unresolved parameter into a Typed one, never rename an + already-resolved wire name. + .OUTPUTS + $null, or [PSCustomObject]@{ WireName; TargetVariable } -- same shape as + Get-PfbWireNameForParameter. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Management.Automation.Language.FunctionDefinitionAst]$FunctionAst, + + [Parameter(Mandatory)] + [string]$ParameterName, + + [switch]$IsSwitchParameter + ) + + # Local predicate: is $Candidate a single-string-key hashtable literal whose one value + # hands $ParameterName to the wire? + $isReferenceObjectFor = { + param($Candidate) + $hash = (Resolve-PfbSingleExpression -Ast $Candidate) -as [System.Management.Automation.Language.HashtableAst] + if (-not $hash) { return $false } + if ($hash.KeyValuePairs.Count -ne 1) { return $false } + $innerPair = $hash.KeyValuePairs[0] + if (-not ($innerPair.Item1 -as [System.Management.Automation.Language.StringConstantExpressionAst])) { return $false } + return (Test-PfbWireValueIsParameter -ValueAst $innerPair.Item2 -ParameterName $ParameterName -IsSwitchParameter:$IsSwitchParameter) + } + + $assignments = @($FunctionAst.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.AssignmentStatementAst] + }, $true)) + + # Index form first, then the literal-initializer form, mirroring the order + # Get-PfbWireNameForParameter uses for the direct shapes. + foreach ($assign in $assignments) { + $indexExpr = $assign.Left -as [System.Management.Automation.Language.IndexExpressionAst] + if (-not $indexExpr) { continue } + $targetVar = $indexExpr.Target -as [System.Management.Automation.Language.VariableExpressionAst] + if (-not $targetVar) { continue } + if ($targetVar.VariablePath.UserPath -notin @('body', 'queryParams')) { continue } + + $keyExpr = $indexExpr.Index -as [System.Management.Automation.Language.StringConstantExpressionAst] + if (-not $keyExpr) { continue } + + if (& $isReferenceObjectFor $assign.Right) { return [PSCustomObject]@{ WireName = $keyExpr.Value TargetVariable = $targetVar.VariablePath.UserPath @@ -147,6 +547,27 @@ function Get-PfbWireNameForParameter { } } + foreach ($assign in $assignments) { + $targetVar = $assign.Left -as [System.Management.Automation.Language.VariableExpressionAst] + if (-not $targetVar) { continue } + if ($targetVar.VariablePath.UserPath -notin @('body', 'queryParams')) { continue } + + $hashtable = (Resolve-PfbSingleExpression -Ast $assign.Right) -as [System.Management.Automation.Language.HashtableAst] + if (-not $hashtable) { continue } + + foreach ($pair in $hashtable.KeyValuePairs) { + $keyExpr = $pair.Item1 -as [System.Management.Automation.Language.StringConstantExpressionAst] + if (-not $keyExpr) { continue } + + if (& $isReferenceObjectFor $pair.Item2) { + return [PSCustomObject]@{ + WireName = $keyExpr.Value + TargetVariable = $targetVar.VariablePath.UserPath + } + } + } + } + return $null }