From ceebcc288969477713b822dd88d90bb8e2d89174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Fri, 17 Jul 2026 22:04:54 +0200 Subject: [PATCH 1/4] Add -DefaultValueType to Should -HaveParameter Surface the parameter default value's expression kind (the AST node type of the default value) so assertions can distinguish an expression default like $Path = (Get-DefaultPath) from a string-literal default like $Path = '(Get-DefaultPath)', which share the same -DefaultValue string. The new -DefaultValueType parameter is added to both the legacy Should -HaveParameter operator and the assert-style Should-HaveParameter, and is matched case-insensitively with an optional trailing 'Ast'. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../assert/Parameter/Should-HaveParameter.ps1 | 10 +++ src/functions/assertions/HaveParameter.ps1 | 45 ++++++++++- .../Parameter/Should-HaveParameter.Tests.ps1 | 47 +++++++++++ .../assertions/HaveParameter.Tests.ps1 | 78 ++++++++++++++++++- 4 files changed, 176 insertions(+), 4 deletions(-) diff --git a/src/functions/assert/Parameter/Should-HaveParameter.ps1 b/src/functions/assert/Parameter/Should-HaveParameter.ps1 index beb67f966..2852cd5a3 100644 --- a/src/functions/assert/Parameter/Should-HaveParameter.ps1 +++ b/src/functions/assert/Parameter/Should-HaveParameter.ps1 @@ -15,6 +15,15 @@ .PARAMETER DefaultValue The default value of the parameter to check. E.g. "https://example.com" + .PARAMETER DefaultValueType + The kind of the parameter's default value, i.e. the AST node type of the default value expression. + This distinguishes how the default is written, which -DefaultValue (a string comparison) cannot. + For example, `$Path = (Get-Date)` is a `ParenExpressionAst` while `$Path = '(Get-Date)'` is a + `StringConstantExpressionAst`. Common values: StringConstantExpressionAst (literal string), + ExpandableStringExpressionAst (interpolated string), ConstantExpressionAst (numeric literal), + VariableExpressionAst (e.g. $true, $false, $null, $var), ParenExpressionAst (e.g. (Get-Date)), + ScriptBlockExpressionAst (e.g. { ... }). The trailing `Ast` is optional and matching is case-insensitive. + .PARAMETER Mandatory Whether the parameter is mandatory or not. @@ -99,6 +108,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, + [String] $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, [Switch] $HasArgumentCompleter, diff --git a/src/functions/assertions/HaveParameter.ps1 b/src/functions/assertions/HaveParameter.ps1 index c7924d029..8dae6a5d1 100644 --- a/src/functions/assertions/HaveParameter.ps1 +++ b/src/functions/assertions/HaveParameter.ps1 @@ -3,6 +3,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, + [String] $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, [Switch] $HasArgumentCompleter, @@ -53,7 +54,7 @@ if ($null -eq $ast) { # Ast is unavailable, ex. for a binary cmdlet - throw [ArgumentException]'Using -DefaultValue is only supported for functions and scripts.' + throw [ArgumentException]'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' } if ($null -ne $ast.Parameters) { @@ -82,7 +83,7 @@ Type = "[$($parameter.StaticType.Name.ToLower())]" HasDefaultValue = $false DefaultValue = $null - DefaultValueType = $parameter.StaticType.Name + DefaultValueType = $null } # Default value here contains a descriptor object of the default value, @@ -99,6 +100,11 @@ # we take that and use it, otherwise we take the extent (how it was written in code). This will make # 1, 2, or "abc", appear as 1, 2, abc to the assertion, but (Get-Date) will be (Get-Date). $paramInfo.DefaultValue = Get-DefaultValue $parameter.DefaultValue + # The AST node type of the default value expression, e.g. StringConstantExpressionAst for + # a literal string like '(Get-Date)', or ParenExpressionAst for an expression like (Get-Date). + # This describes how the default value is written, which -DefaultValue (a string comparison) + # cannot distinguish. + $paramInfo.DefaultValueType = $parameter.DefaultValue.GetType().Name } $paramInfo @@ -240,7 +246,7 @@ elseif ($Negate -and -not $hasKey) { return [Pester.ShouldResult] @{ Succeeded = $true } } - elseif ($Negate -and $hasKey -and -not ($InParameterSet -or $Mandatory -or $Type -or $DefaultValue -or $HasArgumentCompleter)) { + elseif ($Negate -and $hasKey -and -not ($InParameterSet -or $Mandatory -or $Type -or $DefaultValue -or $DefaultValueType -or $HasArgumentCompleter)) { $buts += 'the parameter exists' } else { @@ -329,6 +335,39 @@ } } + if ($PSBoundParameters.ContainsKey('DefaultValueType')) { + $parameterMetadata = Get-ParameterInfo -Name $ParameterName -Command $ActualValue + if ($null -eq $parameterMetadata) { + # For safety, but this probably won't happen because if the parameter is not on the command we will fail much sooner. + throw "Metadata for parameter '$ParameterName' were not found." + } + + $filters += "the default value type$(if ($Negate) {" not"}) to be $(Format-Nicely $DefaultValueType)" + + # DefaultValueType is the AST node type of the parameter's default value expression, e.g. + # StringConstantExpressionAst for a literal string default like '(Get-Date)', or + # ParenExpressionAst for an expression default like (Get-Date). This distinguishes how the + # default value is written, which the string-based -DefaultValue comparison cannot do. + # We match with or without the trailing 'Ast' so both 'ParenExpression' and 'ParenExpressionAst' + # are accepted. Comparison is case-insensitive (default for -eq on strings). + $actualDefaultValueType = $parameterMetadata.DefaultValueType + $normalizedExpected = $DefaultValueType -replace 'Ast$', '' + $normalizedActual = if ($null -eq $actualDefaultValueType) { $null } else { $actualDefaultValueType -replace 'Ast$', '' } + $testDefaultValueType = ($null -ne $actualDefaultValueType) -and ($normalizedActual -eq $normalizedExpected) + + if (-not $Negate -and -not $testDefaultValueType) { + if ($null -eq $actualDefaultValueType) { + $buts += 'the parameter had no default value' + } + else { + $buts += "the default value type was $(Format-Nicely $actualDefaultValueType)" + } + } + elseif ($Negate -and $testDefaultValueType) { + $buts += "the default value type was $(Format-Nicely $actualDefaultValueType)" + } + } + if ($PSBoundParameters.ContainsKey('HasArgumentCompleter')) { $testArgumentCompleter = $attributes | & $SafeCommands['Where-Object'] { $_ -is [ArgumentCompleter] } diff --git a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 index 396634132..4d50d43cb 100644 --- a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 +++ b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 @@ -108,4 +108,51 @@ Describe "Should-HaveParameter" { Get-Command f | Should-HaveParameter a -HasArgumentCompleter:$false } } + + Context "DefaultValueType" { + It "Passes when the default value is an expression and ParenExpression is expected" { + function f { + param([string] $Path = (Get-Date)) + } + + Get-Command f | Should-HaveParameter Path -DefaultValueType ParenExpression + } + + It "Passes when the default value is a literal string and StringConstantExpression is expected" { + function f { + param([string] $Path = '(Get-Date)') + } + + Get-Command f | Should-HaveParameter Path -DefaultValueType StringConstantExpression + } + + It "Distinguishes a string-literal default from an expression default" { + function f { + param( + [string] $Literal = '(Get-Date)', + [string] $Expression = (Get-Date) + ) + } + + # Same -DefaultValue string, different -DefaultValueType (issue #1888) + Get-Command f | Should-HaveParameter Literal -DefaultValue '(Get-Date)' -DefaultValueType StringConstantExpression + Get-Command f | Should-HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType ParenExpression + } + + It "Fails when the default value type does not match" { + function f { + param([string] $Path = '(Get-Date)') + } + + { Get-Command f | Should-HaveParameter Path -DefaultValueType ParenExpression } | Verify-Throw + } + + It "Fails when the parameter has no default value" { + function f { + param([string] $Path) + } + + { Get-Command f | Should-HaveParameter Path -DefaultValueType StringConstantExpression } | Verify-Throw + } + } } diff --git a/tst/functions/assertions/HaveParameter.Tests.ps1 b/tst/functions/assertions/HaveParameter.Tests.ps1 index 99fd7a388..ccf5b295f 100644 --- a/tst/functions/assertions/HaveParameter.Tests.ps1 +++ b/tst/functions/assertions/HaveParameter.Tests.ps1 @@ -287,7 +287,12 @@ InPesterModuleScope { It 'fails if the parameter DefaultValue is used with a binary cmdlet' { $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValue $False } | Verify-Throw - $err.Exception.Message | Verify-Equal 'Using -DefaultValue is only supported for functions and scripts.' + $err.Exception.Message | Verify-Equal 'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' + } + + It 'fails if the parameter DefaultValueType is used with a binary cmdlet' { + $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValueType VariableExpression } | Verify-Throw + $err.Exception.Message | Verify-Equal 'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' } It "fails if the parameter MandatoryParam has no alias 'Second'" { @@ -384,6 +389,58 @@ InPesterModuleScope { } } + Context 'Using DefaultValueType' { + BeforeAll { + function Test-DefaultValueType { + param ( + [string] $LiteralString = '(Get-Date)', + [string] $Expression = (Get-Date), + $ScriptBlockDefault = { Get-Date }, + [int] $Number = 1, + $Bool = $true, + $NoDefault + ) + } + } + + It "passes when the default value is of the expected type " -TestCases @( + @{ ParameterName = 'LiteralString'; DefaultValueType = 'StringConstantExpressionAst' } + @{ ParameterName = 'Expression'; DefaultValueType = 'ParenExpressionAst' } + @{ ParameterName = 'ScriptBlockDefault'; DefaultValueType = 'ScriptBlockExpressionAst' } + @{ ParameterName = 'Number'; DefaultValueType = 'ConstantExpressionAst' } + @{ ParameterName = 'Bool'; DefaultValueType = 'VariableExpressionAst' } + ) { + Get-Command Test-DefaultValueType | Should -HaveParameter $ParameterName -DefaultValueType $DefaultValueType + } + + It "passes when the trailing 'Ast' is omitted and matches case-insensitively" { + Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType ParenExpression + Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType stringconstantexpression + } + + It "distinguishes an expression default from a string-literal default" { + # This is the core scenario from issue #1888: (Get-Date) vs '(Get-Date)' share the same + # -DefaultValue string, but differ in their DefaultValueType. + Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType ParenExpression + Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue '(Get-Date)' -DefaultValueType StringConstantExpression + } + + It "fails when the default value type does not match" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType ParenExpression } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value type to be 'ParenExpression', but the default value type was 'StringConstantExpressionAst'." + } + + It "fails when the parameter has no default value" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType VariableExpression } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value type to be 'VariableExpression', but the parameter had no default value." + } + + It "returns a combined message when both DefaultValue and DefaultValueType differ" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue 'wrong' -DefaultValueType ParenExpression -Because 'of reasons' } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be 'wrong' and the default value type to be 'ParenExpression', because of reasons, but the default value was '(Get-Date)' and the default value type was 'StringConstantExpressionAst'." + } + } + Context 'Using InParameterSet' { It "passes if parameter exist in parameter set " -TestCases @( @{ParameterName = 'ParamWithNotNullOrEmptyValidation'; ParameterSetName = 'PrimarySet' } @@ -540,6 +597,25 @@ InPesterModuleScope { { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValue $ExpectedValue } | Verify-AssertionFailed } + It "passes if the parameter default value type is not " -TestCases @( + @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "StringConstantExpression" } + @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "ParenExpression" } + ) { + Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValueType $DefaultValueType + } + + It "fails if the parameter default value type is " -TestCases @( + @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "ParenExpression" } + @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "StringConstantExpression" } + ) { + { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValueType $DefaultValueType } | Verify-AssertionFailed + } + + It "returns the correct assertion message when the default value type should not match but does" { + $err = { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter ParamWithScriptValidation -DefaultValueType StringConstantExpression -Because 'of reasons' } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Invoke-DummyFunction to not have a parameter ParamWithScriptValidation, the default value type not to be 'StringConstantExpression', because of reasons, but the default value type was 'StringConstantExpressionAst'." + } + It "fails if the parameter is of type or has a default value of ''" -TestCases @( @{ParameterName = "MandatoryParam"; ExpectedType = [Object]; ExpectedValue = "" } @{ParameterName = "ParamWithNotNullOrEmptyValidation"; ExpectedType = [DateTime]; ExpectedValue = "(Get-Date)" } From 1fc6c4c90845a6a77498efa9413893937479b528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Sat, 18 Jul 2026 22:17:03 +0200 Subject: [PATCH 2/4] Use friendly kinds for -DefaultValueType instead of AST type names The first version required the raw AST node type, like ParenExpressionAst or StringConstantExpressionAst, which you cannot know without reading the PowerShell parser internals. Use a ValidateSet of friendly kinds instead: String, InterpolatedString, Number, Variable, Expression, ScriptBlock, Array, Hashtable, and map each to the AST type(s) internally. This gives tab completion on Should-HaveParameter and a clear error that lists the valid kinds when you pass something wrong, instead of a confusing assertion failure. Failure messages show the friendly kind on both sides now. $true, $false and $null are Variable, and the help says so. Co-Authored-By: Claude Opus 4.8 --- .../assert/Parameter/Should-HaveParameter.ps1 | 15 ++--- src/functions/assertions/HaveParameter.ps1 | 58 ++++++++++++----- .../Parameter/Should-HaveParameter.Tests.ps1 | 24 ++++--- .../assertions/HaveParameter.Tests.ps1 | 63 +++++++++++-------- 4 files changed, 105 insertions(+), 55 deletions(-) diff --git a/src/functions/assert/Parameter/Should-HaveParameter.ps1 b/src/functions/assert/Parameter/Should-HaveParameter.ps1 index 2852cd5a3..505963b2e 100644 --- a/src/functions/assert/Parameter/Should-HaveParameter.ps1 +++ b/src/functions/assert/Parameter/Should-HaveParameter.ps1 @@ -16,13 +16,13 @@ The default value of the parameter to check. E.g. "https://example.com" .PARAMETER DefaultValueType - The kind of the parameter's default value, i.e. the AST node type of the default value expression. - This distinguishes how the default is written, which -DefaultValue (a string comparison) cannot. - For example, `$Path = (Get-Date)` is a `ParenExpressionAst` while `$Path = '(Get-Date)'` is a - `StringConstantExpressionAst`. Common values: StringConstantExpressionAst (literal string), - ExpandableStringExpressionAst (interpolated string), ConstantExpressionAst (numeric literal), - VariableExpressionAst (e.g. $true, $false, $null, $var), ParenExpressionAst (e.g. (Get-Date)), - ScriptBlockExpressionAst (e.g. { ... }). The trailing `Ast` is optional and matching is case-insensitive. + The kind of the parameter's default value. This distinguishes how the default is written, which + -DefaultValue (a string comparison) cannot: for example `$Path = (Get-Date)` is an `Expression` + while `$Path = '(Get-Date)'` is a `String`. Valid values: + `String` (literal string, e.g. 'abc' or "abc"), `InterpolatedString` (e.g. "$x/bar"), + `Number` (numeric literal, e.g. 1), `Variable` (e.g. $true, $false, $null, $var), + `Expression` (e.g. (Get-Date)), `ScriptBlock` (e.g. { Get-Date }), `Array` (e.g. @(1, 2) or 1, 2), + `Hashtable` (e.g. @{}). Matching is case-insensitive. .PARAMETER Mandatory Whether the parameter is mandatory or not. @@ -108,6 +108,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, + [ValidateSet('String', 'InterpolatedString', 'Number', 'Variable', 'Expression', 'ScriptBlock', 'Array', 'Hashtable')] [String] $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, diff --git a/src/functions/assertions/HaveParameter.ps1 b/src/functions/assertions/HaveParameter.ps1 index 8dae6a5d1..5e0a3ccb2 100644 --- a/src/functions/assertions/HaveParameter.ps1 +++ b/src/functions/assertions/HaveParameter.ps1 @@ -3,6 +3,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, + [ValidateSet('String', 'InterpolatedString', 'Number', 'Variable', 'Expression', 'ScriptBlock', 'Array', 'Hashtable')] [String] $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, @@ -103,7 +104,8 @@ # The AST node type of the default value expression, e.g. StringConstantExpressionAst for # a literal string like '(Get-Date)', or ParenExpressionAst for an expression like (Get-Date). # This describes how the default value is written, which -DefaultValue (a string comparison) - # cannot distinguish. + # cannot distinguish. The -DefaultValueType assertion maps this raw AST type to a friendly + # kind (e.g. String, Expression) so callers don't need to know PowerShell AST type names. $paramInfo.DefaultValueType = $parameter.DefaultValue.GetType().Name } @@ -342,29 +344,55 @@ throw "Metadata for parameter '$ParameterName' were not found." } - $filters += "the default value type$(if ($Negate) {" not"}) to be $(Format-Nicely $DefaultValueType)" + # -DefaultValueType asserts the *kind* of the parameter's default value, so you can tell an + # expression default like (Get-Date) apart from a literal string '(Get-Date)' that -DefaultValue + # (a plain string comparison) cannot distinguish. Users specify a friendly kind (e.g. Expression), + # not the underlying PowerShell AST node type (e.g. ParenExpressionAst) which is a parser + # implementation detail. This maps each friendly kind to the AST node type(s) that represent it. + $defaultValueTypeMap = [ordered]@{ + String = @('StringConstantExpressionAst') + InterpolatedString = @('ExpandableStringExpressionAst') + Number = @('ConstantExpressionAst') + Variable = @('VariableExpressionAst') + Expression = @('ParenExpressionAst') + ScriptBlock = @('ScriptBlockExpressionAst') + Array = @('ArrayExpressionAst', 'ArrayLiteralAst') + Hashtable = @('HashtableAst') + } + + # Canonical casing of the expected kind for the message and lookup (ValidateSet allows any casing). + $expectedKind = @($defaultValueTypeMap.Keys | & $SafeCommands['Where-Object'] { $_ -eq $DefaultValueType })[0] + $filters += "the default value type$(if ($Negate) {" not"}) to be $(Format-Nicely $expectedKind)" + + # The raw AST node type name of the actual default, or $null when the parameter has no default. + $actualAst = $parameterMetadata.DefaultValueType + # Friendly kind of the actual default for the message. Fall back to the AST type name (without the + # trailing 'Ast') for the rare default value kinds we don't have a friendly name for. + $actualKind = $null + if ($null -ne $actualAst) { + foreach ($key in $defaultValueTypeMap.Keys) { + if ($defaultValueTypeMap[$key] -contains $actualAst) { + $actualKind = $key + break + } + } + if ($null -eq $actualKind) { + $actualKind = $actualAst -replace 'Ast$', '' + } + } - # DefaultValueType is the AST node type of the parameter's default value expression, e.g. - # StringConstantExpressionAst for a literal string default like '(Get-Date)', or - # ParenExpressionAst for an expression default like (Get-Date). This distinguishes how the - # default value is written, which the string-based -DefaultValue comparison cannot do. - # We match with or without the trailing 'Ast' so both 'ParenExpression' and 'ParenExpressionAst' - # are accepted. Comparison is case-insensitive (default for -eq on strings). - $actualDefaultValueType = $parameterMetadata.DefaultValueType - $normalizedExpected = $DefaultValueType -replace 'Ast$', '' - $normalizedActual = if ($null -eq $actualDefaultValueType) { $null } else { $actualDefaultValueType -replace 'Ast$', '' } - $testDefaultValueType = ($null -ne $actualDefaultValueType) -and ($normalizedActual -eq $normalizedExpected) + $testDefaultValueType = ($null -ne $actualAst) -and ($defaultValueTypeMap[$expectedKind] -contains $actualAst) if (-not $Negate -and -not $testDefaultValueType) { - if ($null -eq $actualDefaultValueType) { + if ($null -eq $actualAst) { $buts += 'the parameter had no default value' } else { - $buts += "the default value type was $(Format-Nicely $actualDefaultValueType)" + $buts += "the default value type was $(Format-Nicely $actualKind)" } } elseif ($Negate -and $testDefaultValueType) { - $buts += "the default value type was $(Format-Nicely $actualDefaultValueType)" + $buts += "the default value type was $(Format-Nicely $actualKind)" } } diff --git a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 index 4d50d43cb..4745aafbe 100644 --- a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 +++ b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 @@ -110,20 +110,20 @@ Describe "Should-HaveParameter" { } Context "DefaultValueType" { - It "Passes when the default value is an expression and ParenExpression is expected" { + It "Passes when the default value is an expression and Expression is expected" { function f { param([string] $Path = (Get-Date)) } - Get-Command f | Should-HaveParameter Path -DefaultValueType ParenExpression + Get-Command f | Should-HaveParameter Path -DefaultValueType Expression } - It "Passes when the default value is a literal string and StringConstantExpression is expected" { + It "Passes when the default value is a literal string and String is expected" { function f { param([string] $Path = '(Get-Date)') } - Get-Command f | Should-HaveParameter Path -DefaultValueType StringConstantExpression + Get-Command f | Should-HaveParameter Path -DefaultValueType String } It "Distinguishes a string-literal default from an expression default" { @@ -135,8 +135,8 @@ Describe "Should-HaveParameter" { } # Same -DefaultValue string, different -DefaultValueType (issue #1888) - Get-Command f | Should-HaveParameter Literal -DefaultValue '(Get-Date)' -DefaultValueType StringConstantExpression - Get-Command f | Should-HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType ParenExpression + Get-Command f | Should-HaveParameter Literal -DefaultValue '(Get-Date)' -DefaultValueType String + Get-Command f | Should-HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType Expression } It "Fails when the default value type does not match" { @@ -144,7 +144,7 @@ Describe "Should-HaveParameter" { param([string] $Path = '(Get-Date)') } - { Get-Command f | Should-HaveParameter Path -DefaultValueType ParenExpression } | Verify-Throw + { Get-Command f | Should-HaveParameter Path -DefaultValueType Expression } | Verify-Throw } It "Fails when the parameter has no default value" { @@ -152,7 +152,15 @@ Describe "Should-HaveParameter" { param([string] $Path) } - { Get-Command f | Should-HaveParameter Path -DefaultValueType StringConstantExpression } | Verify-Throw + { Get-Command f | Should-HaveParameter Path -DefaultValueType String } | Verify-Throw + } + + It "Rejects an unknown default value type at parameter binding" { + function f { + param([string] $Path = '(Get-Date)') + } + + { Get-Command f | Should-HaveParameter Path -DefaultValueType NotAKind } | Verify-Throw } } } diff --git a/tst/functions/assertions/HaveParameter.Tests.ps1 b/tst/functions/assertions/HaveParameter.Tests.ps1 index ccf5b295f..5a0db1f51 100644 --- a/tst/functions/assertions/HaveParameter.Tests.ps1 +++ b/tst/functions/assertions/HaveParameter.Tests.ps1 @@ -291,7 +291,7 @@ InPesterModuleScope { } It 'fails if the parameter DefaultValueType is used with a binary cmdlet' { - $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValueType VariableExpression } | Verify-Throw + $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValueType Variable } | Verify-Throw $err.Exception.Message | Verify-Equal 'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' } @@ -394,50 +394,63 @@ InPesterModuleScope { function Test-DefaultValueType { param ( [string] $LiteralString = '(Get-Date)', + [string] $Interpolated = "$x bar", [string] $Expression = (Get-Date), $ScriptBlockDefault = { Get-Date }, [int] $Number = 1, $Bool = $true, + $Null = $null, + $ArrayDefault = @(1, 2), + $HashtableDefault = @{ a = 1 }, $NoDefault ) } } - It "passes when the default value is of the expected type " -TestCases @( - @{ ParameterName = 'LiteralString'; DefaultValueType = 'StringConstantExpressionAst' } - @{ ParameterName = 'Expression'; DefaultValueType = 'ParenExpressionAst' } - @{ ParameterName = 'ScriptBlockDefault'; DefaultValueType = 'ScriptBlockExpressionAst' } - @{ ParameterName = 'Number'; DefaultValueType = 'ConstantExpressionAst' } - @{ ParameterName = 'Bool'; DefaultValueType = 'VariableExpressionAst' } + It "passes when the default value is of the expected kind " -TestCases @( + @{ ParameterName = 'LiteralString'; DefaultValueType = 'String' } + @{ ParameterName = 'Interpolated'; DefaultValueType = 'InterpolatedString' } + @{ ParameterName = 'Expression'; DefaultValueType = 'Expression' } + @{ ParameterName = 'ScriptBlockDefault'; DefaultValueType = 'ScriptBlock' } + @{ ParameterName = 'Number'; DefaultValueType = 'Number' } + @{ ParameterName = 'Bool'; DefaultValueType = 'Variable' } + @{ ParameterName = 'Null'; DefaultValueType = 'Variable' } + @{ ParameterName = 'ArrayDefault'; DefaultValueType = 'Array' } + @{ ParameterName = 'HashtableDefault'; DefaultValueType = 'Hashtable' } ) { Get-Command Test-DefaultValueType | Should -HaveParameter $ParameterName -DefaultValueType $DefaultValueType } - It "passes when the trailing 'Ast' is omitted and matches case-insensitively" { - Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType ParenExpression - Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType stringconstantexpression + It "matches case-insensitively" { + Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType expression + Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType STRING } It "distinguishes an expression default from a string-literal default" { # This is the core scenario from issue #1888: (Get-Date) vs '(Get-Date)' share the same - # -DefaultValue string, but differ in their DefaultValueType. - Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType ParenExpression - Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue '(Get-Date)' -DefaultValueType StringConstantExpression + # -DefaultValue string, but differ in their kind. + Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType Expression + Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue '(Get-Date)' -DefaultValueType String + } + + It "throws a clear error listing the valid kinds when given an unknown default value type" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType NotAKind } | Verify-Throw + $err.Exception.Message | Verify-Like "*Cannot validate argument on parameter 'DefaultValueType'*" } It "fails when the default value type does not match" { - $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType ParenExpression } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value type to be 'ParenExpression', but the default value type was 'StringConstantExpressionAst'." + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType Expression } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value type to be 'Expression', but the default value type was 'String'." } It "fails when the parameter has no default value" { - $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType VariableExpression } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value type to be 'VariableExpression', but the parameter had no default value." + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType Variable } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value type to be 'Variable', but the parameter had no default value." } It "returns a combined message when both DefaultValue and DefaultValueType differ" { - $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue 'wrong' -DefaultValueType ParenExpression -Because 'of reasons' } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be 'wrong' and the default value type to be 'ParenExpression', because of reasons, but the default value was '(Get-Date)' and the default value type was 'StringConstantExpressionAst'." + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue 'wrong' -DefaultValueType Expression -Because 'of reasons' } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be 'wrong' and the default value type to be 'Expression', because of reasons, but the default value was '(Get-Date)' and the default value type was 'String'." } } @@ -598,22 +611,22 @@ InPesterModuleScope { } It "passes if the parameter default value type is not " -TestCases @( - @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "StringConstantExpression" } - @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "ParenExpression" } + @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "String" } + @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "Expression" } ) { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValueType $DefaultValueType } It "fails if the parameter default value type is " -TestCases @( - @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "ParenExpression" } - @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "StringConstantExpression" } + @{ParameterName = "ParamWithNotNullOrEmptyValidation"; DefaultValueType = "Expression" } + @{ParameterName = "ParamWithScriptValidation"; DefaultValueType = "String" } ) { { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter $ParameterName -DefaultValueType $DefaultValueType } | Verify-AssertionFailed } It "returns the correct assertion message when the default value type should not match but does" { - $err = { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter ParamWithScriptValidation -DefaultValueType StringConstantExpression -Because 'of reasons' } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Invoke-DummyFunction to not have a parameter ParamWithScriptValidation, the default value type not to be 'StringConstantExpression', because of reasons, but the default value type was 'StringConstantExpressionAst'." + $err = { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter ParamWithScriptValidation -DefaultValueType String -Because 'of reasons' } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Invoke-DummyFunction to not have a parameter ParamWithScriptValidation, the default value type not to be 'String', because of reasons, but the default value type was 'String'." } It "fails if the parameter is of type or has a default value of ''" -TestCases @( From 63840c2b96ecae8438b39ac2977374e4ccc1f063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Sat, 18 Jul 2026 22:28:04 +0200 Subject: [PATCH 3/4] Report -DefaultValueType as the value type, not the AST kind $true is Boolean now, not Variable. A default has a real type when the parser can type it statically, so report that: Boolean, Null, Number, String, ScriptBlock, Array, Hashtable. A computed default like (Get-Date) or [datetime]::Now has no static type, PowerShell reports it as System.Object, so it stays Expression, which is also what tells it apart from a literal string default. Interpolated strings are String, a bare $var is Expression. Co-Authored-By: Claude Opus 4.8 --- .../assert/Parameter/Should-HaveParameter.ps1 | 17 ++-- src/functions/assertions/HaveParameter.ps1 | 97 ++++++++++--------- .../Parameter/Should-HaveParameter.Tests.ps1 | 8 ++ .../assertions/HaveParameter.Tests.ps1 | 30 +++--- 4 files changed, 89 insertions(+), 63 deletions(-) diff --git a/src/functions/assert/Parameter/Should-HaveParameter.ps1 b/src/functions/assert/Parameter/Should-HaveParameter.ps1 index 505963b2e..c7e225a88 100644 --- a/src/functions/assert/Parameter/Should-HaveParameter.ps1 +++ b/src/functions/assert/Parameter/Should-HaveParameter.ps1 @@ -16,13 +16,14 @@ The default value of the parameter to check. E.g. "https://example.com" .PARAMETER DefaultValueType - The kind of the parameter's default value. This distinguishes how the default is written, which - -DefaultValue (a string comparison) cannot: for example `$Path = (Get-Date)` is an `Expression` - while `$Path = '(Get-Date)'` is a `String`. Valid values: - `String` (literal string, e.g. 'abc' or "abc"), `InterpolatedString` (e.g. "$x/bar"), - `Number` (numeric literal, e.g. 1), `Variable` (e.g. $true, $false, $null, $var), - `Expression` (e.g. (Get-Date)), `ScriptBlock` (e.g. { Get-Date }), `Array` (e.g. @(1, 2) or 1, 2), - `Hashtable` (e.g. @{}). Matching is case-insensitive. + The value type of the parameter's default. Valid values: + `Boolean` (`$true`, `$false`), `Null` (`$null`), `Number` (e.g. 1, 1.5), `String` (a literal or + interpolated string, e.g. 'abc' or "$x bar"), `ScriptBlock` (e.g. { Get-Date }), `Array` (e.g. @(1, 2)), + `Hashtable` (e.g. @{ a = 1 }), and `Expression` for a computed default whose type is not known until + it runs (e.g. (Get-Date), [datetime]::Now, $someVariable). + `Expression` vs `String` is what tells an expression default apart from a literal string default, which + -DefaultValue (a string comparison) cannot: for example `$Path = (Get-Date)` is an `Expression` while + `$Path = '(Get-Date)'` is a `String`. Matching is case-insensitive. .PARAMETER Mandatory Whether the parameter is mandatory or not. @@ -108,7 +109,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, - [ValidateSet('String', 'InterpolatedString', 'Number', 'Variable', 'Expression', 'ScriptBlock', 'Array', 'Hashtable')] + [ValidateSet('Boolean', 'Null', 'Number', 'String', 'ScriptBlock', 'Array', 'Hashtable', 'Expression')] [String] $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, diff --git a/src/functions/assertions/HaveParameter.ps1 b/src/functions/assertions/HaveParameter.ps1 index 5e0a3ccb2..cd3b4abe8 100644 --- a/src/functions/assertions/HaveParameter.ps1 +++ b/src/functions/assertions/HaveParameter.ps1 @@ -3,7 +3,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, - [ValidateSet('String', 'InterpolatedString', 'Number', 'Variable', 'Expression', 'ScriptBlock', 'Array', 'Hashtable')] + [ValidateSet('Boolean', 'Null', 'Number', 'String', 'ScriptBlock', 'Array', 'Hashtable', 'Expression')] [String] $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, @@ -101,12 +101,11 @@ # we take that and use it, otherwise we take the extent (how it was written in code). This will make # 1, 2, or "abc", appear as 1, 2, abc to the assertion, but (Get-Date) will be (Get-Date). $paramInfo.DefaultValue = Get-DefaultValue $parameter.DefaultValue - # The AST node type of the default value expression, e.g. StringConstantExpressionAst for - # a literal string like '(Get-Date)', or ParenExpressionAst for an expression like (Get-Date). - # This describes how the default value is written, which -DefaultValue (a string comparison) - # cannot distinguish. The -DefaultValueType assertion maps this raw AST type to a friendly - # kind (e.g. String, Expression) so callers don't need to know PowerShell AST type names. - $paramInfo.DefaultValueType = $parameter.DefaultValue.GetType().Name + # The value type of the default, e.g. Boolean for $true, String for a literal string, or + # Expression for a computed default like (Get-Date) whose type is not known statically. + # This is what -DefaultValueType asserts, and Expression vs String is what tells a literal + # string default apart from an expression that -DefaultValue (a string comparison) cannot. + $paramInfo.DefaultValueType = Get-DefaultValueKind $parameter.DefaultValue } $paramInfo @@ -140,6 +139,41 @@ $DefaultValue.Extent.Text } + function Get-DefaultValueKind { + # Classifies the parameter's default value by its value type, so -DefaultValueType can be asserted + # with a real type name (Boolean, Number, String, ...) rather than a PowerShell AST type name. + # The type is only known for values the parser can type statically. A computed default like + # (Get-Date), [datetime]::Now or $someVariable has no static type (PowerShell reports it as + # System.Object), so it is reported as an Expression, which is also what tells it apart from a + # string literal default that happens to have the same -DefaultValue text. + param($DefaultValue) + + # $true, $false and $null are variable references in the AST, but their value type is known, + # so report them as Boolean and Null instead of a generic Expression. Any other variable + # ($var, $env:PATH) has a type we cannot know without running it, so it stays an Expression. + if ($DefaultValue -is [System.Management.Automation.Language.VariableExpressionAst]) { + $variableName = $DefaultValue.VariablePath.UserPath + if ('true' -eq $variableName -or 'false' -eq $variableName) { return 'Boolean' } + if ('null' -eq $variableName) { return 'Null' } + return 'Expression' + } + + $staticType = $DefaultValue.StaticType + if ($null -eq $staticType) { return 'Expression' } + + if ([string] -eq $staticType) { return 'String' } + if ([bool] -eq $staticType) { return 'Boolean' } + if ([scriptblock] -eq $staticType) { return 'ScriptBlock' } + if ([hashtable] -eq $staticType) { return 'Hashtable' } + if ($staticType.IsArray) { return 'Array' } + + $numericTypes = @([byte], [sbyte], [int16], [uint16], [int], [uint32], [int64], [uint64], [single], [double], [decimal]) + if ($numericTypes -contains $staticType) { return 'Number' } + + # System.Object (a computed expression) or any type we don't have a friendly name for. + return 'Expression' + } + function Get-ArgumentCompleter { <# .SYNOPSIS @@ -344,47 +378,22 @@ throw "Metadata for parameter '$ParameterName' were not found." } - # -DefaultValueType asserts the *kind* of the parameter's default value, so you can tell an - # expression default like (Get-Date) apart from a literal string '(Get-Date)' that -DefaultValue - # (a plain string comparison) cannot distinguish. Users specify a friendly kind (e.g. Expression), - # not the underlying PowerShell AST node type (e.g. ParenExpressionAst) which is a parser - # implementation detail. This maps each friendly kind to the AST node type(s) that represent it. - $defaultValueTypeMap = [ordered]@{ - String = @('StringConstantExpressionAst') - InterpolatedString = @('ExpandableStringExpressionAst') - Number = @('ConstantExpressionAst') - Variable = @('VariableExpressionAst') - Expression = @('ParenExpressionAst') - ScriptBlock = @('ScriptBlockExpressionAst') - Array = @('ArrayExpressionAst', 'ArrayLiteralAst') - Hashtable = @('HashtableAst') - } - - # Canonical casing of the expected kind for the message and lookup (ValidateSet allows any casing). - $expectedKind = @($defaultValueTypeMap.Keys | & $SafeCommands['Where-Object'] { $_ -eq $DefaultValueType })[0] + # -DefaultValueType asserts the value type of the parameter's default (Boolean, Number, String, + # ScriptBlock, Array, Hashtable, Null), and Expression for a computed default like (Get-Date) + # whose type is not known statically. Expression vs String is what tells an expression default + # apart from a literal string default that -DefaultValue (a string comparison) cannot. + # Get-ParameterInfo already resolved the actual default to one of these kinds. + $validKinds = 'Boolean', 'Null', 'Number', 'String', 'ScriptBlock', 'Array', 'Hashtable', 'Expression' + # Canonical casing of the expected kind for the message (ValidateSet allows any casing). + $expectedKind = @($validKinds | & $SafeCommands['Where-Object'] { $_ -eq $DefaultValueType })[0] $filters += "the default value type$(if ($Negate) {" not"}) to be $(Format-Nicely $expectedKind)" - # The raw AST node type name of the actual default, or $null when the parameter has no default. - $actualAst = $parameterMetadata.DefaultValueType - # Friendly kind of the actual default for the message. Fall back to the AST type name (without the - # trailing 'Ast') for the rare default value kinds we don't have a friendly name for. - $actualKind = $null - if ($null -ne $actualAst) { - foreach ($key in $defaultValueTypeMap.Keys) { - if ($defaultValueTypeMap[$key] -contains $actualAst) { - $actualKind = $key - break - } - } - if ($null -eq $actualKind) { - $actualKind = $actualAst -replace 'Ast$', '' - } - } - - $testDefaultValueType = ($null -ne $actualAst) -and ($defaultValueTypeMap[$expectedKind] -contains $actualAst) + # The value type of the actual default, or $null when the parameter has no default value. + $actualKind = $parameterMetadata.DefaultValueType + $testDefaultValueType = ($null -ne $actualKind) -and ($actualKind -eq $expectedKind) if (-not $Negate -and -not $testDefaultValueType) { - if ($null -eq $actualAst) { + if ($null -eq $actualKind) { $buts += 'the parameter had no default value' } else { diff --git a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 index 4745aafbe..c26ea6ec6 100644 --- a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 +++ b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 @@ -126,6 +126,14 @@ Describe "Should-HaveParameter" { Get-Command f | Should-HaveParameter Path -DefaultValueType String } + It "Reports the value type, so a `$true default is Boolean, not a variable" { + function f { + param($Enabled = $true) + } + + Get-Command f | Should-HaveParameter Enabled -DefaultValueType Boolean + } + It "Distinguishes a string-literal default from an expression default" { function f { param( diff --git a/tst/functions/assertions/HaveParameter.Tests.ps1 b/tst/functions/assertions/HaveParameter.Tests.ps1 index 5a0db1f51..127c8ab49 100644 --- a/tst/functions/assertions/HaveParameter.Tests.ps1 +++ b/tst/functions/assertions/HaveParameter.Tests.ps1 @@ -291,7 +291,7 @@ InPesterModuleScope { } It 'fails if the parameter DefaultValueType is used with a binary cmdlet' { - $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValueType Variable } | Verify-Throw + $err = { Get-Command 'Get-Content' | Should -HaveParameter Force -DefaultValueType Boolean } | Verify-Throw $err.Exception.Message | Verify-Equal 'Using -DefaultValue or -DefaultValueType is only supported for functions and scripts.' } @@ -396,9 +396,13 @@ InPesterModuleScope { [string] $LiteralString = '(Get-Date)', [string] $Interpolated = "$x bar", [string] $Expression = (Get-Date), + $Member = [datetime]::Now, + $Variable = $env:PATH, $ScriptBlockDefault = { Get-Date }, [int] $Number = 1, + $Double = 1.5, $Bool = $true, + $BoolFalse = $false, $Null = $null, $ArrayDefault = @(1, 2), $HashtableDefault = @{ a = 1 }, @@ -407,14 +411,18 @@ InPesterModuleScope { } } - It "passes when the default value is of the expected kind " -TestCases @( + It "passes when the default value is of the expected type " -TestCases @( @{ ParameterName = 'LiteralString'; DefaultValueType = 'String' } - @{ ParameterName = 'Interpolated'; DefaultValueType = 'InterpolatedString' } + @{ ParameterName = 'Interpolated'; DefaultValueType = 'String' } @{ ParameterName = 'Expression'; DefaultValueType = 'Expression' } + @{ ParameterName = 'Member'; DefaultValueType = 'Expression' } + @{ ParameterName = 'Variable'; DefaultValueType = 'Expression' } @{ ParameterName = 'ScriptBlockDefault'; DefaultValueType = 'ScriptBlock' } @{ ParameterName = 'Number'; DefaultValueType = 'Number' } - @{ ParameterName = 'Bool'; DefaultValueType = 'Variable' } - @{ ParameterName = 'Null'; DefaultValueType = 'Variable' } + @{ ParameterName = 'Double'; DefaultValueType = 'Number' } + @{ ParameterName = 'Bool'; DefaultValueType = 'Boolean' } + @{ ParameterName = 'BoolFalse'; DefaultValueType = 'Boolean' } + @{ ParameterName = 'Null'; DefaultValueType = 'Null' } @{ ParameterName = 'ArrayDefault'; DefaultValueType = 'Array' } @{ ParameterName = 'HashtableDefault'; DefaultValueType = 'Hashtable' } ) { @@ -423,18 +431,18 @@ InPesterModuleScope { It "matches case-insensitively" { Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType expression - Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType STRING + Get-Command Test-DefaultValueType | Should -HaveParameter Bool -DefaultValueType boolean } It "distinguishes an expression default from a string-literal default" { # This is the core scenario from issue #1888: (Get-Date) vs '(Get-Date)' share the same - # -DefaultValue string, but differ in their kind. + # -DefaultValue string, but differ in their type. Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType Expression Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue '(Get-Date)' -DefaultValueType String } - It "throws a clear error listing the valid kinds when given an unknown default value type" { - $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType NotAKind } | Verify-Throw + It "throws a clear error listing the valid types when given an unknown default value type" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType NotAType } | Verify-Throw $err.Exception.Message | Verify-Like "*Cannot validate argument on parameter 'DefaultValueType'*" } @@ -444,8 +452,8 @@ InPesterModuleScope { } It "fails when the parameter has no default value" { - $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType Variable } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value type to be 'Variable', but the parameter had no default value." + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType Boolean } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value type to be 'Boolean', but the parameter had no default value." } It "returns a combined message when both DefaultValue and DefaultValueType differ" { From e28b462bc8bd9a3659825412503a7a4e8792e3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Sun, 19 Jul 2026 10:01:21 +0200 Subject: [PATCH 4/4] Make -DefaultValueType take a real type or Expression The two things #1888 wants to tell apart, (Get-DefaultPath) and '(Get-DefaultPath)', are the same type, so a type alone cannot separate them. The real difference is that one is a computed expression and one is a literal. So -DefaultValueType now takes a real .NET type, as a [type] or a type name the same way -Type does, matched against the type of a literal default ($Force = $false is [bool], $Retries = 3 is [int]). For a computed default like (Get-Date) whose type is not known until it runs, pass the special value Expression. Expression vs a concrete type is what distinguishes the two cases from the issue. Co-Authored-By: Claude Opus 4.8 --- .../assert/Parameter/Should-HaveParameter.ps1 | 19 ++- src/functions/assertions/HaveParameter.ps1 | 128 ++++++++++-------- .../Parameter/Should-HaveParameter.Tests.ps1 | 14 +- .../assertions/HaveParameter.Tests.ps1 | 71 ++++++---- 4 files changed, 135 insertions(+), 97 deletions(-) diff --git a/src/functions/assert/Parameter/Should-HaveParameter.ps1 b/src/functions/assert/Parameter/Should-HaveParameter.ps1 index c7e225a88..e3b005907 100644 --- a/src/functions/assert/Parameter/Should-HaveParameter.ps1 +++ b/src/functions/assert/Parameter/Should-HaveParameter.ps1 @@ -16,14 +16,14 @@ The default value of the parameter to check. E.g. "https://example.com" .PARAMETER DefaultValueType - The value type of the parameter's default. Valid values: - `Boolean` (`$true`, `$false`), `Null` (`$null`), `Number` (e.g. 1, 1.5), `String` (a literal or - interpolated string, e.g. 'abc' or "$x bar"), `ScriptBlock` (e.g. { Get-Date }), `Array` (e.g. @(1, 2)), - `Hashtable` (e.g. @{ a = 1 }), and `Expression` for a computed default whose type is not known until - it runs (e.g. (Get-Date), [datetime]::Now, $someVariable). - `Expression` vs `String` is what tells an expression default apart from a literal string default, which - -DefaultValue (a string comparison) cannot: for example `$Path = (Get-Date)` is an `Expression` while - `$Path = '(Get-Date)'` is a `String`. Matching is case-insensitive. + The .NET type of the parameter's default value, as a type or a type name (the same way -Type is given), + e.g. `([string])`, `[int]`, `bool` or `datetime`. This matches literal defaults whose type is known, so + `$Force = $false` is `[bool]` and `$Retries = 3` is `[int]`. + Pass the special value `Expression` for a computed default whose type is not known until it runs, e.g. + `(Get-Date)`, `[datetime]::Now` or `$someVariable`. `Expression` vs a concrete type is what tells an + expression default apart from a literal string default, which -DefaultValue (a string comparison) + cannot: for example `$Path = (Get-DefaultPath)` is an `Expression` while `$Path = '(Get-DefaultPath)'` + is `[string]`. .PARAMETER Mandatory Whether the parameter is mandatory or not. @@ -109,8 +109,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, - [ValidateSet('Boolean', 'Null', 'Number', 'String', 'ScriptBlock', 'Array', 'Hashtable', 'Expression')] - [String] $DefaultValueType, + $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, [Switch] $HasArgumentCompleter, diff --git a/src/functions/assertions/HaveParameter.ps1 b/src/functions/assertions/HaveParameter.ps1 index cd3b4abe8..eb193fa5b 100644 --- a/src/functions/assertions/HaveParameter.ps1 +++ b/src/functions/assertions/HaveParameter.ps1 @@ -3,8 +3,7 @@ [String] $ParameterName, $Type, [String] $DefaultValue, - [ValidateSet('Boolean', 'Null', 'Number', 'String', 'ScriptBlock', 'Array', 'Hashtable', 'Expression')] - [String] $DefaultValueType, + $DefaultValueType, [Switch] $Mandatory, [String] $InParameterSet, [Switch] $HasArgumentCompleter, @@ -80,11 +79,12 @@ } $paramInfo = [PSCustomObject] @{ - Name = $parameter.Name.VariablePath.UserPath - Type = "[$($parameter.StaticType.Name.ToLower())]" - HasDefaultValue = $false - DefaultValue = $null - DefaultValueType = $null + Name = $parameter.Name.VariablePath.UserPath + Type = "[$($parameter.StaticType.Name.ToLower())]" + HasDefaultValue = $false + DefaultValue = $null + DefaultValueType = $null + DefaultValueIsExpression = $false } # Default value here contains a descriptor object of the default value, @@ -101,11 +101,14 @@ # we take that and use it, otherwise we take the extent (how it was written in code). This will make # 1, 2, or "abc", appear as 1, 2, abc to the assertion, but (Get-Date) will be (Get-Date). $paramInfo.DefaultValue = Get-DefaultValue $parameter.DefaultValue - # The value type of the default, e.g. Boolean for $true, String for a literal string, or - # Expression for a computed default like (Get-Date) whose type is not known statically. - # This is what -DefaultValueType asserts, and Expression vs String is what tells a literal - # string default apart from an expression that -DefaultValue (a string comparison) cannot. - $paramInfo.DefaultValueType = Get-DefaultValueKind $parameter.DefaultValue + # The .NET type of the default value when the parser can type it statically (a literal like + # 1, 'x', $true, { }, @{ }), and whether the default is a computed expression like (Get-Date) + # whose type is not known until it runs. -DefaultValueType asserts against these: a concrete + # type for a literal, or Expression for a computed default. Expression vs a concrete type is + # what tells an expression default apart from a literal that -DefaultValue cannot. + $defaultValueTypeInfo = Get-DefaultValueTypeInfo $parameter.DefaultValue + $paramInfo.DefaultValueType = $defaultValueTypeInfo.Type + $paramInfo.DefaultValueIsExpression = $defaultValueTypeInfo.IsExpression } $paramInfo @@ -139,39 +142,33 @@ $DefaultValue.Extent.Text } - function Get-DefaultValueKind { - # Classifies the parameter's default value by its value type, so -DefaultValueType can be asserted - # with a real type name (Boolean, Number, String, ...) rather than a PowerShell AST type name. - # The type is only known for values the parser can type statically. A computed default like - # (Get-Date), [datetime]::Now or $someVariable has no static type (PowerShell reports it as - # System.Object), so it is reported as an Expression, which is also what tells it apart from a - # string literal default that happens to have the same -DefaultValue text. + function Get-DefaultValueTypeInfo { + # Resolves the value type of a parameter's default. Returns .Type, the actual .NET type when the + # parser can type it statically (a literal like 1, 'x', $true, { }, @{ }), and .IsExpression, which + # is $true when the default is a computed expression like (Get-Date) or [datetime]::Now whose type + # is only known when it runs. A computed expression is what tells a default apart from a literal + # that happens to have the same -DefaultValue text. A $null default has neither a type nor is an + # expression. param($DefaultValue) - # $true, $false and $null are variable references in the AST, but their value type is known, - # so report them as Boolean and Null instead of a generic Expression. Any other variable - # ($var, $env:PATH) has a type we cannot know without running it, so it stays an Expression. + # $true / $false have a known type; $null has no type; any other variable ($var, $env:PATH) is a + # computed reference whose type we cannot know without running it. if ($DefaultValue -is [System.Management.Automation.Language.VariableExpressionAst]) { $variableName = $DefaultValue.VariablePath.UserPath - if ('true' -eq $variableName -or 'false' -eq $variableName) { return 'Boolean' } - if ('null' -eq $variableName) { return 'Null' } - return 'Expression' + if ('true' -eq $variableName -or 'false' -eq $variableName) { return @{ Type = [bool]; IsExpression = $false } } + if ('null' -eq $variableName) { return @{ Type = $null; IsExpression = $false } } + return @{ Type = $null; IsExpression = $true } } + # System.Object means the parser could not determine a type, i.e. the default is a computed + # expression such as (Get-Date), [datetime]::Now or a pipeline. Anything else (String, Int32, + # Double, ScriptBlock, Hashtable, Object[], ...) is a literal we can type. $staticType = $DefaultValue.StaticType - if ($null -eq $staticType) { return 'Expression' } - - if ([string] -eq $staticType) { return 'String' } - if ([bool] -eq $staticType) { return 'Boolean' } - if ([scriptblock] -eq $staticType) { return 'ScriptBlock' } - if ([hashtable] -eq $staticType) { return 'Hashtable' } - if ($staticType.IsArray) { return 'Array' } - - $numericTypes = @([byte], [sbyte], [int16], [uint16], [int], [uint32], [int64], [uint64], [single], [double], [decimal]) - if ($numericTypes -contains $staticType) { return 'Number' } + if ($null -eq $staticType -or [object] -eq $staticType) { + return @{ Type = $null; IsExpression = $true } + } - # System.Object (a computed expression) or any type we don't have a friendly name for. - return 'Expression' + return @{ Type = $staticType; IsExpression = $false } } function Get-ArgumentCompleter { @@ -378,30 +375,49 @@ throw "Metadata for parameter '$ParameterName' were not found." } - # -DefaultValueType asserts the value type of the parameter's default (Boolean, Number, String, - # ScriptBlock, Array, Hashtable, Null), and Expression for a computed default like (Get-Date) - # whose type is not known statically. Expression vs String is what tells an expression default - # apart from a literal string default that -DefaultValue (a string comparison) cannot. - # Get-ParameterInfo already resolved the actual default to one of these kinds. - $validKinds = 'Boolean', 'Null', 'Number', 'String', 'ScriptBlock', 'Array', 'Hashtable', 'Expression' - # Canonical casing of the expected kind for the message (ValidateSet allows any casing). - $expectedKind = @($validKinds | & $SafeCommands['Where-Object'] { $_ -eq $DefaultValueType })[0] - $filters += "the default value type$(if ($Negate) {" not"}) to be $(Format-Nicely $expectedKind)" - - # The value type of the actual default, or $null when the parameter has no default value. - $actualKind = $parameterMetadata.DefaultValueType - $testDefaultValueType = ($null -ne $actualKind) -and ($actualKind -eq $expectedKind) - - if (-not $Negate -and -not $testDefaultValueType) { - if ($null -eq $actualKind) { - $buts += 'the parameter had no default value' + # -DefaultValueType takes a .NET type (as a [type] or a string like 'string', same as -Type), + # which is matched against the value type of a literal default, or the special value 'Expression' + # for a computed default like (Get-Date) whose type is not known until it runs. Expression vs a + # concrete type is what tells an expression default apart from a literal string default that + # -DefaultValue (a string comparison) cannot distinguish. + $expectExpression = ($DefaultValueType -is [string]) -and ('Expression' -eq $DefaultValueType) + + # Describe the actual default the same way for every mismatch below. + $actualDefaultDescription = + if (-not $parameterMetadata.HasDefaultValue) { 'the parameter had no default value' } + elseif ($parameterMetadata.DefaultValueIsExpression) { 'the default value was an expression' } + elseif ($null -eq $parameterMetadata.DefaultValueType) { 'the default value was $null' } + else { "the default value was of type [$($parameterMetadata.DefaultValueType.FullName)]" } + + if ($expectExpression) { + $filters += "the default value$(if ($Negate) {' not'}) to be an expression" + $testDefaultValueType = $parameterMetadata.HasDefaultValue -and $parameterMetadata.DefaultValueIsExpression + $negatedBut = 'the default value was an expression' + } + else { + # Resolve the expected type, parsing a string type name the way -Type does. + if ($DefaultValueType -is [string]) { + $trimmedType = $DefaultValueType -replace '^\[(.*)\]$', '$1' + [type] $expectedType = $trimmedType -as [Type] + if ($null -eq $expectedType) { + throw [ArgumentException]"Could not find type [$trimmedType]. Make sure that the assembly that contains that type is loaded, or use -DefaultValueType Expression to check for a computed default value." + } } else { - $buts += "the default value type was $(Format-Nicely $actualKind)" + [type] $expectedType = $DefaultValueType } + + $filters += "the default value$(if ($Negate) {' not'}) to be of type [$($expectedType.FullName)]" + $actualType = $parameterMetadata.DefaultValueType + $testDefaultValueType = ($null -ne $actualType) -and ($expectedType -eq $actualType) + $negatedBut = "the default value was of type [$($expectedType.FullName)]" + } + + if (-not $Negate -and -not $testDefaultValueType) { + $buts += $actualDefaultDescription } elseif ($Negate -and $testDefaultValueType) { - $buts += "the default value type was $(Format-Nicely $actualKind)" + $buts += $negatedBut } } diff --git a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 index c26ea6ec6..8c36dc19c 100644 --- a/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 +++ b/tst/functions/assert/Parameter/Should-HaveParameter.Tests.ps1 @@ -118,20 +118,22 @@ Describe "Should-HaveParameter" { Get-Command f | Should-HaveParameter Path -DefaultValueType Expression } - It "Passes when the default value is a literal string and String is expected" { + It "Passes when the default value is a literal string and its type is expected" { function f { param([string] $Path = '(Get-Date)') } Get-Command f | Should-HaveParameter Path -DefaultValueType String + Get-Command f | Should-HaveParameter Path -DefaultValueType ([string]) } - It "Reports the value type, so a `$true default is Boolean, not a variable" { + It "Reports the real type, so a `$true default is [bool] and a number is [int]" { function f { - param($Enabled = $true) + param($Enabled = $true, $Retries = 3) } - Get-Command f | Should-HaveParameter Enabled -DefaultValueType Boolean + Get-Command f | Should-HaveParameter Enabled -DefaultValueType ([bool]) + Get-Command f | Should-HaveParameter Retries -DefaultValueType int } It "Distinguishes a string-literal default from an expression default" { @@ -163,12 +165,12 @@ Describe "Should-HaveParameter" { { Get-Command f | Should-HaveParameter Path -DefaultValueType String } | Verify-Throw } - It "Rejects an unknown default value type at parameter binding" { + It "Throws when given a type name that does not exist" { function f { param([string] $Path = '(Get-Date)') } - { Get-Command f | Should-HaveParameter Path -DefaultValueType NotAKind } | Verify-Throw + { Get-Command f | Should-HaveParameter Path -DefaultValueType NotAType } | Verify-Throw } } } diff --git a/tst/functions/assertions/HaveParameter.Tests.ps1 b/tst/functions/assertions/HaveParameter.Tests.ps1 index 127c8ab49..0c421d977 100644 --- a/tst/functions/assertions/HaveParameter.Tests.ps1 +++ b/tst/functions/assertions/HaveParameter.Tests.ps1 @@ -411,54 +411,75 @@ InPesterModuleScope { } } - It "passes when the default value is of the expected type " -TestCases @( - @{ ParameterName = 'LiteralString'; DefaultValueType = 'String' } - @{ ParameterName = 'Interpolated'; DefaultValueType = 'String' } - @{ ParameterName = 'Expression'; DefaultValueType = 'Expression' } - @{ ParameterName = 'Member'; DefaultValueType = 'Expression' } - @{ ParameterName = 'Variable'; DefaultValueType = 'Expression' } - @{ ParameterName = 'ScriptBlockDefault'; DefaultValueType = 'ScriptBlock' } - @{ ParameterName = 'Number'; DefaultValueType = 'Number' } - @{ ParameterName = 'Double'; DefaultValueType = 'Number' } - @{ ParameterName = 'Bool'; DefaultValueType = 'Boolean' } - @{ ParameterName = 'BoolFalse'; DefaultValueType = 'Boolean' } - @{ ParameterName = 'Null'; DefaultValueType = 'Null' } - @{ ParameterName = 'ArrayDefault'; DefaultValueType = 'Array' } - @{ ParameterName = 'HashtableDefault'; DefaultValueType = 'Hashtable' } + It "passes when the literal default is of type " -TestCases @( + @{ ParameterName = 'LiteralString'; DefaultValueType = 'string' } + @{ ParameterName = 'Interpolated'; DefaultValueType = 'string' } + @{ ParameterName = 'ScriptBlockDefault'; DefaultValueType = 'scriptblock' } + @{ ParameterName = 'Number'; DefaultValueType = 'int' } + @{ ParameterName = 'Number'; DefaultValueType = 'Int32' } + @{ ParameterName = 'Double'; DefaultValueType = 'double' } + @{ ParameterName = 'Bool'; DefaultValueType = 'bool' } + @{ ParameterName = 'BoolFalse'; DefaultValueType = 'bool' } + @{ ParameterName = 'HashtableDefault'; DefaultValueType = 'hashtable' } + @{ ParameterName = 'ArrayDefault'; DefaultValueType = 'object[]' } ) { Get-Command Test-DefaultValueType | Should -HaveParameter $ParameterName -DefaultValueType $DefaultValueType } - It "matches case-insensitively" { + It "passes when the type is given as a type object rather than a string" { + Get-Command Test-DefaultValueType | Should -HaveParameter Number -DefaultValueType ([int]) + Get-Command Test-DefaultValueType | Should -HaveParameter Bool -DefaultValueType ([bool]) + Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType ([string]) + } + + It "passes when the computed default is an Expression" -TestCases @( + @{ ParameterName = 'Expression' } + @{ ParameterName = 'Member' } + @{ ParameterName = 'Variable' } + ) { + Get-Command Test-DefaultValueType | Should -HaveParameter $ParameterName -DefaultValueType Expression + } + + It "Expression matches case-insensitively" { Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType expression - Get-Command Test-DefaultValueType | Should -HaveParameter Bool -DefaultValueType boolean } It "distinguishes an expression default from a string-literal default" { # This is the core scenario from issue #1888: (Get-Date) vs '(Get-Date)' share the same - # -DefaultValue string, but differ in their type. + # -DefaultValue string. The literal is a [string], the expression has no static type. Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValue '(Get-Date)' -DefaultValueType Expression Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue '(Get-Date)' -DefaultValueType String } - It "throws a clear error listing the valid types when given an unknown default value type" { + It "throws when given a type name that does not exist" { $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType NotAType } | Verify-Throw - $err.Exception.Message | Verify-Like "*Cannot validate argument on parameter 'DefaultValueType'*" + $err.Exception | Verify-Type ([ArgumentException]) + $err.Exception.Message | Verify-Equal 'Could not find type [NotAType]. Make sure that the assembly that contains that type is loaded, or use -DefaultValueType Expression to check for a computed default value.' + } + + It "fails when the literal default is of a different type" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType ([int]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be of type [System.Int32], but the default value was of type [System.String]." + } + + It "fails when a concrete type is expected but the default is an expression" { + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter Expression -DefaultValueType ([string]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter Expression, the default value to be of type [System.String], but the default value was an expression." } - It "fails when the default value type does not match" { + It "fails when Expression is expected but the default is a literal" { $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValueType Expression } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value type to be 'Expression', but the default value type was 'String'." + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be an expression, but the default value was of type [System.String]." } It "fails when the parameter has no default value" { - $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType Boolean } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value type to be 'Boolean', but the parameter had no default value." + $err = { Get-Command Test-DefaultValueType | Should -HaveParameter NoDefault -DefaultValueType ([string]) } | Verify-AssertionFailed + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter NoDefault, the default value to be of type [System.String], but the parameter had no default value." } It "returns a combined message when both DefaultValue and DefaultValueType differ" { $err = { Get-Command Test-DefaultValueType | Should -HaveParameter LiteralString -DefaultValue 'wrong' -DefaultValueType Expression -Because 'of reasons' } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be 'wrong' and the default value type to be 'Expression', because of reasons, but the default value was '(Get-Date)' and the default value type was 'String'." + $err.Exception.Message | Verify-Equal "Expected command Test-DefaultValueType to have a parameter LiteralString, the default value to be 'wrong' and the default value to be an expression, because of reasons, but the default value was '(Get-Date)' and the default value was of type [System.String]." } } @@ -634,7 +655,7 @@ InPesterModuleScope { It "returns the correct assertion message when the default value type should not match but does" { $err = { Get-Command "Invoke-DummyFunction" | Should -Not -HaveParameter ParamWithScriptValidation -DefaultValueType String -Because 'of reasons' } | Verify-AssertionFailed - $err.Exception.Message | Verify-Equal "Expected command Invoke-DummyFunction to not have a parameter ParamWithScriptValidation, the default value type not to be 'String', because of reasons, but the default value type was 'String'." + $err.Exception.Message | Verify-Equal "Expected command Invoke-DummyFunction to not have a parameter ParamWithScriptValidation, the default value not to be of type [System.String], because of reasons, but the default value was of type [System.String]." } It "fails if the parameter is of type or has a default value of ''" -TestCases @(