diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index 7f3e31b..541ea8f 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -18,6 +18,7 @@ IncludeRules = @('*') ExcludeRules = @( + 'PSUseProcessBlockForPipelineCommand' 'PSUseCorrectCasing' 'PSUseShouldProcessForStateChangingFunctions' ) @@ -81,6 +82,10 @@ Enable = $true } + 'Measure-ExplicitParameterAttribute' = @{ + Enable = $true + } + 'Measure-CanonicalAttributeOrder' = @{ Enable = $true } diff --git a/analyzers/HouseRules.psm1 b/analyzers/HouseRules.psm1 index 7e778ad..04a63ff 100644 --- a/analyzers/HouseRules.psm1 +++ b/analyzers/HouseRules.psm1 @@ -11,15 +11,33 @@ Function ConvertTo-HouseRuleDiagnosticRecord { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.IScriptExtent] $Extent, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.String] $Message, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.String] $RuleName ) @@ -47,7 +65,13 @@ Function Get-HouseRuleFunctionAst { )] [OutputType([System.Management.Automation.Language.FunctionDefinitionAst[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -79,11 +103,23 @@ Function Test-HouseRuleAstBelongsToFunction { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.Ast] $Ast, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -114,7 +150,13 @@ Function Get-HouseRuleVariableName { )] [OutputType([System.String])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.VariableExpressionAst] $VariableAst ) @@ -141,7 +183,13 @@ Function Test-HouseRuleAutomaticVariable { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.String] $Name ) @@ -214,7 +262,13 @@ Function Get-HouseRuleParameterName { )] [OutputType([System.String[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -241,7 +295,13 @@ Function Get-HouseRuleIteratorName { )] [OutputType([System.String[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -316,7 +376,13 @@ Function Get-HouseRuleStaticString { )] [OutputType([System.String[]])] Param ( - [Parameter()] + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [AllowNull()] [System.Management.Automation.Language.Ast] $Ast @@ -347,11 +413,23 @@ Function Get-HouseRuleCommandArgument { )] [OutputType([System.Management.Automation.Language.Ast[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.CommandAst] $CommandAst, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.String] $ParameterName ) @@ -393,11 +471,23 @@ Function Get-HouseRuleCommandArgumentString { )] [OutputType([System.String[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.CommandAst] $CommandAst, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.String] $ParameterName ) @@ -420,7 +510,13 @@ Function Test-HouseRuleCommandHasPrivateOption { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.CommandAst] $CommandAst ) @@ -444,7 +540,13 @@ Function Test-HouseRuleCommandUsesNonLocalScope { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.CommandAst] $CommandAst ) @@ -468,7 +570,13 @@ Function Get-HouseRuleAssignedExpressionVariable { )] [OutputType([PSCustomObject[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.Ast] $Ast ) @@ -502,7 +610,13 @@ Function Get-HouseRuleAssignedVariable { )] [OutputType([PSCustomObject[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -567,11 +681,23 @@ Function Get-HouseRulePrivateDeclarationName { )] [OutputType([System.String[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst, - [Parameter()] + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [AllowNull()] [System.Management.Automation.Language.Ast] $SearchAst = $Null @@ -631,7 +757,13 @@ Function Test-HouseRulePipelineParameter { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -687,7 +819,13 @@ Function Test-HouseRuleNamedBlock { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -712,7 +850,13 @@ Function Get-HouseRuleNamedBlockAst { )] [OutputType([System.Management.Automation.Language.NamedBlockAst[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -752,12 +896,24 @@ Function Get-HouseRuleFunctionAttribute { )] [OutputType([System.Management.Automation.Language.AttributeAst[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [ValidateNotNullOrEmpty()] [System.String] $AttributeName, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -790,7 +946,13 @@ Function Get-HouseRuleAttributeName { )] [OutputType([System.String])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.AttributeAst] $AttributeAst ) @@ -817,7 +979,13 @@ Function Get-HouseRuleParameterAttributeOrderKey { )] [OutputType([System.String])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.AttributeBaseAst] $AttributeAst ) @@ -853,7 +1021,13 @@ Function Test-HouseRuleAlphabeticalOrder { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [AllowEmptyCollection()] [System.String[]] $Value @@ -882,7 +1056,13 @@ Function Test-HouseRuleNamedArgumentValueIsTrue { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.NamedAttributeArgumentAst] $NamedArgument ) @@ -916,11 +1096,23 @@ Function Get-HouseRuleNamedAttributeArgument { )] [OutputType([System.Management.Automation.Language.NamedAttributeArgumentAst])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.String] $ArgumentName, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.AttributeAst] $AttributeAst ) @@ -948,12 +1140,24 @@ Function Test-HouseRuleNamedArgumentValueEqual { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [AllowNull()] [System.Object] $ExpectedValue, - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.NamedAttributeArgumentAst] $NamedArgument ) @@ -973,6 +1177,133 @@ Function Test-HouseRuleNamedArgumentValueEqual { } +Function Get-HouseRuleExplicitParameterAttributeDiagnostic { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] + Param ( + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] + [System.String] + $OwnerLabel, + + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] + [AllowEmptyCollection()] + [System.Management.Automation.Language.ParameterAst[]] + $ParameterAst + ) + + [System.String[]]$Private:RequiredOptionNames = @( + 'DontShow', + 'Mandatory', + 'ParameterSetName', + 'ValueFromPipeline', + 'ValueFromPipelineByPropertyName' + ) + [System.Collections.Generic.Dictionary[System.String, System.String]]$Private:ForbiddenReasonByName = ( + [System.Collections.Generic.Dictionary[System.String, System.String]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + ) + $ForbiddenReasonByName.Add( + 'HelpMessage', + 'comment-based .PARAMETER help owns help text' + ) + $ForbiddenReasonByName.Add( + 'Position', + 'it re-enables positional binding even when CmdletBinding(PositionalBinding = $False)' + ) + $ForbiddenReasonByName.Add( + 'ValueFromRemainingArguments', + 'the project does not use remaining-argument capture' + ) + + ForEach ($Parameter In $ParameterAst) { + [System.String]$Private:ParameterName = Get-HouseRuleVariableName -VariableAst $Parameter.Name + [System.Management.Automation.Language.AttributeAst[]]$Private:ParameterAttributes = @( + $Parameter.Attributes | + Where-Object -FilterScript { + $PSItem -is [System.Management.Automation.Language.AttributeAst] -and + (Get-HouseRuleAttributeName -AttributeAst $PSItem) -ieq 'Parameter' + } + ) + + If ($ParameterAttributes.Count -eq 0) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-ExplicitParameterAttribute' ` + -Extent $Parameter.Extent ` + -Message ( + "{0} parameter '{1}' is missing the Parameter attribute required by SG-7." -f + $OwnerLabel, + $ParameterName + ) + + Continue + } + + ForEach ($AttributeAst In $ParameterAttributes) { + [System.Collections.Generic.HashSet[System.String]]$Private:SeenOptionNames = ( + [System.Collections.Generic.HashSet[System.String]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + ) + + ForEach ($NamedArgument In $AttributeAst.NamedArguments) { + [void]$SeenOptionNames.Add($NamedArgument.ArgumentName) + + If ($ForbiddenReasonByName.ContainsKey($NamedArgument.ArgumentName) -eq $False) { + Continue + } + + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-ExplicitParameterAttribute' ` + -Extent $NamedArgument.Extent ` + -Message ( + "{0} parameter '{1}' must not declare Parameter({2}); {3} (SG-7)." -f + $OwnerLabel, + $ParameterName, + $NamedArgument.ArgumentName, + $ForbiddenReasonByName[$NamedArgument.ArgumentName] + ) + } + + ForEach ($RequiredOptionName In $RequiredOptionNames) { + If ($SeenOptionNames.Contains($RequiredOptionName) -eq $True) { + Continue + } + + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-ExplicitParameterAttribute' ` + -Extent $AttributeAst.Extent ` + -Message ( + "{0} parameter '{1}' Parameter attribute is missing explicit option '{2}' required by SG-7." -f + $OwnerLabel, + $ParameterName, + $RequiredOptionName + ) + } + } + } + +} + Function Test-HouseRuleParameterAttributeOrder { [CmdletBinding( ConfirmImpact = 'None', @@ -984,7 +1315,13 @@ Function Test-HouseRuleParameterAttributeOrder { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ParameterAst] $ParameterAst ) @@ -1024,7 +1361,13 @@ Function Test-HouseRuleParameterTypeLast { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ParameterAst] $ParameterAst ) @@ -1061,7 +1404,13 @@ Function Test-HouseRuleParameterOrderGuard { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -1115,7 +1464,13 @@ Function Get-HouseRuleProcessResetVariableName { )] [OutputType([System.String[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.NamedBlockAst] $ProcessBlock ) @@ -1180,7 +1535,13 @@ Function Test-HouseRuleEnteringProcessDebugStatement { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.StatementAst] $StatementAst ) @@ -1216,7 +1577,13 @@ Function Test-HouseRuleExitingDebugStatement { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.StatementAst] $StatementAst ) @@ -1252,7 +1619,13 @@ Function Test-HouseRuleFunctionDeclaresPrivateResult { )] [OutputType([System.Boolean])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @@ -1280,7 +1653,13 @@ Function Test-HouseRuleLastStatementExitingDebug { )] [OutputType([System.Boolean])] Param ( - [Parameter()] + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [AllowNull()] [System.Management.Automation.Language.NamedBlockAst] $BlockAst @@ -1312,7 +1691,13 @@ Function Measure-CanonicalAttributeOrder { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -1436,12 +1821,18 @@ Function Measure-CanonicalKeywordCasing { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - If ($Null -eq $ScriptBlockAst.Parent) { + If ($Null -eq $ScriptBlockAst.Parent -and $ScriptBlockAst.Extent.StartOffset -eq 0) { $Private:CanonicalKeywords = [System.Collections.Generic.Dictionary[System.String, System.String]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -1551,7 +1942,13 @@ Function Measure-ExplicitCmdletBinding { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -1650,6 +2047,52 @@ Function Measure-ExplicitCmdletBinding { } +Function Measure-ExplicitParameterAttribute { + <# + .SYNOPSIS + Flags parameters missing the house explicit Parameter attribute surface. + #> + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] + Param ( + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] + [System.Management.Automation.Language.ScriptBlockAst] + $ScriptBlockAst + ) + + If ($Null -eq $ScriptBlockAst.Parent) { + If ($Null -ne $ScriptBlockAst.ParamBlock) { + Get-HouseRuleExplicitParameterAttributeDiagnostic ` + -OwnerLabel 'Script' ` + -ParameterAst $ScriptBlockAst.ParamBlock.Parameters + } + + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + If ($Null -eq $FunctionAst.Body.ParamBlock) { + Continue + } + + Get-HouseRuleExplicitParameterAttributeDiagnostic ` + -OwnerLabel ("Function '{0}'" -f $FunctionAst.Name) ` + -ParameterAst $FunctionAst.Body.ParamBlock.Parameters + } + } + +} + Function Measure-PrivateVariableDeclaration { <# .SYNOPSIS @@ -1665,7 +2108,13 @@ Function Measure-PrivateVariableDeclaration { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -1729,7 +2178,13 @@ Function Measure-PipelineVariableLifecycle { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -1834,7 +2289,13 @@ Function Measure-FlatNonPipelineFunction { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -1874,7 +2335,13 @@ Function Measure-CanonicalNamedBlock { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -1936,7 +2403,13 @@ Function Measure-NoRemoveVariableCleanup { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -1988,7 +2461,13 @@ Function Measure-NoNewVariableDeclaration { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) @@ -2037,7 +2516,13 @@ Function Measure-SoftReturn { )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] Param ( - [Parameter(Mandatory = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) diff --git a/docs/STYLE-GUIDE.md b/docs/STYLE-GUIDE.md index 94d5939..a33c772 100644 --- a/docs/STYLE-GUIDE.md +++ b/docs/STYLE-GUIDE.md @@ -349,7 +349,8 @@ so diffs and reviews are stable. Four clauses: - **(a) `CmdletBinding` options** appear in **alphabetical** order: `ConfirmImpact, DefaultParameterSetName, HelpUri, PositionalBinding, SupportsPaging, SupportsShouldProcess`. - **(b) `[Parameter(...)]` named arguments** appear in **alphabetical** order - (`Mandatory, Position?, ValueFromPipeline, ValueFromPipelineByPropertyName, …`). + (`DontShow, Mandatory, ParameterSetName, ValueFromPipeline, + ValueFromPipelineByPropertyName` per SG-7). - **(c) Attributes on a parameter** appear in this canonical order: `[Parameter(...)]` → `[Alias(...)]` → validation/transformation attributes (`[Validate*]`, etc., **alphabetical** among themselves) → **the type literal** → @@ -509,3 +510,142 @@ Process { Write-Debug -Message:'[ConvertTo-Thing] Exiting Process' } ``` + +--- + +## SG-7 — Explicit parameter surface **[mechanical — analyzer-enforced]** + +See [ADR-repo/0008](decision-records/repo/0008-sg7-explicit-parameter-surface.md) +for the accepted rationale and Microsoft-source references. + +**Rule.** Every parameter declares exactly one `[Parameter(...)]` attribute with +exactly five named arguments, in SG-5 alphabetical order: + +1. `DontShow` +2. `Mandatory` +3. `ParameterSetName` +4. `ValueFromPipeline` +5. `ValueFromPipelineByPropertyName` + +Values are **per-parameter** and behavior-preserving: `Mandatory`, +`ValueFromPipeline`, and `ValueFromPipelineByPropertyName` are `$True` only where the +parameter actually has that binding contract; otherwise they are explicitly +`$False`. `ParameterSetName` is `'default'` for the current single-set house model. +`DontShow` is `$False` except for a deliberately hidden test seam. + +**Forbidden.** Do not declare `Position`, `HelpMessage`, or +`ValueFromRemainingArguments` in `[Parameter(...)]`. + +**Exempt.** The `$StoreFactory` test seam in `Get-StoreCertificate` uses +`DontShow = $True` because it exists only for deterministic tests and is not a CLI +surface. The seam still declares the other four SG-7 options explicitly. + +**Why.** The explicit five-option surface is an auditability house preference. +Declaring a Boolean option at its default value is binding-equivalent to omitting it, +so `Mandatory = $False`, `ValueFromPipeline = $False`, and +`ValueFromPipelineByPropertyName = $False` are not behavior changes. In this +single-parameter-set project, `ParameterSetName = 'default'` makes the house set +visible without adding an alternate binding path. + +The forbid-list is correctness, not taste: + +- `Position` is prohibited because it overrides `CmdletBinding(PositionalBinding = + $False)`: any declared parameter position re-enables positional binding for that + parameter. +- `ValueFromPipelineByPropertyName` is a per-parameter choice. Blanket `$True` + accepts matching object properties or aliases by name, which can silently bind a + property the function did not intend to consume. +- `HelpMessage` duplicates the comment-based `.PARAMETER` help used by this repo and + only adds interactive value at the mandatory-parameter `!?` prompt. +- `ValueFromRemainingArguments` is a catch-all positional capture mechanism, and this + project has no remaining-argument surface. + +**Enforced by:** + +- custom rule `Measure-ExplicitParameterAttribute` (Warning) — every parameter must + have `[Parameter(...)]`, must declare the five SG-7 options, and must not declare + the SG-7 forbid-list; +- custom rule `Measure-CanonicalAttributeOrder` (Warning) — the five options must + stay in SG-5 alphabetical order. + +### Example + +```powershell +[Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False +)] +[ValidateNotNullOrEmpty()] +[System.String] +$Path +``` + +### Example (hidden test seam) + +```powershell +[Parameter( + DontShow = $True, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False +)] +[ValidateNotNull()] +[System.Management.Automation.ScriptBlock] +$StoreFactory +``` + +--- + +## SG-8 — Centralized message table **[judgment — review-enforced]** + +See [ADR-repo/0009](decision-records/repo/0009-sg8-centralized-message-table.md) +for the accepted rationale and source references. + +**Rule.** User-facing message strings live in one script-scope +`$Script:Message` hashtable. Author message entries per function as co-located +file-scope fragments, immediately before or after the owning function: + +```powershell +$Script:Message += @{ + 'Get-Thing.MissingPath' = 'Path ''{0}'' does not exist.' +} +``` + +The build emits `[System.Collections.Hashtable]$Script:Message = @{}` at the top +of the merged functions artifact before the first fragment, so `$Script:Message += +@{ ... }` is StrictMode-safe. Call sites index the table directly and format at the +point of use: + +```powershell +New-ErrorRecord -Message:($Script:Message['Get-Thing.MissingPath'] -f $Path) +``` + +Message values are plain single-quoted hashtable strings. Do not use `data {}` or +`ConvertFrom-StringData` for the house message table. Keys are namespaced as +`FunctionName.Purpose`; duplicate keys fail during merge because hashtable addition +throws, giving collision detection. Do not write inline user-facing `-Message` +literals, and do not introduce `$FailureMessage`-style intermediate variables whose +only purpose is to hold a formatted message. + +`Write-Debug` text is explicitly out of scope. Debug anchors are diagnostic trace +strings, not user-facing messages, and they stay inline so SG-6's function-flow +shape remains visible. + +**Why.** The convention gives user-facing text a single lookup surface without +moving messages away from the function that owns them. Per-function fragments keep +small-file review ergonomic while the build still produces one merged table for the +single-script artifact. + +**Honest framing.** For a tool this small, one hand-written central table would also +be correct. The house chooses co-located fragments because the owning function, +tests, and messages stay together during normal edits. This mirrors the DSC +per-resource string-table habit, but keeps the table in script because this +repository intentionally ships a single English-only script instead of localized +resource files. + +**Enforced by:** review. There is no analyzer rule yet; a future +`Measure-*` rule may make the mechanical parts enforceable. diff --git a/docs/reference/clean-function-idiom.ps1 b/docs/reference/clean-function-idiom.ps1 index bca4a11..c43f7a8 100644 --- a/docs/reference/clean-function-idiom.ps1 +++ b/docs/reference/clean-function-idiom.ps1 @@ -30,8 +30,11 @@ Function Get-TemplateGreeting { [OutputType([System.String])] Param ( [Parameter( + DontShow = $False, Mandatory = $True, - ValueFromPipeline = $True + ParameterSetName = 'default', + ValueFromPipeline = $True, + ValueFromPipelineByPropertyName = $False )] [ValidateNotNullOrEmpty()] [System.String] diff --git a/src/SampleModule/Private/Format-GreetingName.ps1 b/src/SampleModule/Private/Format-GreetingName.ps1 index 8cf352a..26733cb 100644 --- a/src/SampleModule/Private/Format-GreetingName.ps1 +++ b/src/SampleModule/Private/Format-GreetingName.ps1 @@ -25,7 +25,13 @@ Function Format-GreetingName { )] [OutputType([System.String])] Param ( - [Parameter(Mandatory = $True, ValueFromPipeline = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $True, + ValueFromPipelineByPropertyName = $False + )] [ValidateNotNull()] [System.String] $Name diff --git a/src/SampleModule/Public/Get-Greeting.ps1 b/src/SampleModule/Public/Get-Greeting.ps1 index fa49883..31da9a6 100644 --- a/src/SampleModule/Public/Get-Greeting.ps1 +++ b/src/SampleModule/Public/Get-Greeting.ps1 @@ -38,12 +38,24 @@ Function Get-Greeting { )] [OutputType([System.String])] Param ( - [Parameter()] + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [ValidateNotNullOrEmpty()] [System.String] $Greeting = 'Hello', - [Parameter(Mandatory = $True, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] + [Parameter( + DontShow = $False, + Mandatory = $True, + ParameterSetName = 'default', + ValueFromPipeline = $True, + ValueFromPipelineByPropertyName = $True + )] [ValidateNotNullOrEmpty()] [System.String] $Name diff --git a/tests/HouseRules.Tests.ps1 b/tests/HouseRules.Tests.ps1 index dc8f5b1..c15dc8f 100644 --- a/tests/HouseRules.Tests.ps1 +++ b/tests/HouseRules.Tests.ps1 @@ -643,3 +643,98 @@ function ConvertTo-Thing { $Results | Should -HaveCount 0 } } + +Describe 'SG-7 house analyzer rules' { + BeforeAll { + $script:AnalyzerRulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\analyzers\HouseRules.psm1' + If (-not (Get-Module -Name PSScriptAnalyzer)) { + Import-Module -Name PSScriptAnalyzer -ErrorAction Stop + } + } + + It 'flags Parameter attributes missing explicit options' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding()] + param ( + [Parameter()] + [System.String] + $Name + ) + $Name +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-ExplicitParameterAttribute' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-ExplicitParameterAttribute' }) + + $Results.RuleName | Should -Contain 'Measure-ExplicitParameterAttribute' + [System.String]$MessageText = $Results.Message -join "`n" + $MessageText | Should -Match 'DontShow' + $MessageText | Should -Match 'ValueFromPipelineByPropertyName' + } + + It 'flags Parameter Position because it re-enables positional binding' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding()] + param ( + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + Position = 0, + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] + [System.String] + $Name + ) + $Name +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-ExplicitParameterAttribute' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-ExplicitParameterAttribute' }) + + $Results.RuleName | Should -Contain 'Measure-ExplicitParameterAttribute' + $Results.Message | Should -Match 're-enables positional binding' + } + + It 'accepts the complete explicit Parameter attribute surface' { + $ScriptDefinition = @' +function Get-Thing { + [CmdletBinding()] + param ( + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] + [System.String] + $Name + ) + $Name +} +'@ + + $Results = Invoke-ScriptAnalyzer ` + -ScriptDefinition $ScriptDefinition ` + -CustomRulePath $script:AnalyzerRulePath ` + -IncludeRule 'Measure-ExplicitParameterAttribute' + + $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-ExplicitParameterAttribute' }) + + $Results | Should -HaveCount 0 + } +} diff --git a/tests/Invoke-Tests.ps1 b/tests/Invoke-Tests.ps1 index 6e13d1f..7550b6d 100644 --- a/tests/Invoke-Tests.ps1 +++ b/tests/Invoke-Tests.ps1 @@ -21,7 +21,21 @@ #> [CmdletBinding()] Param ( + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [string]$OutputPath = '', + [Parameter( + DontShow = $False, + Mandatory = $False, + ParameterSetName = 'default', + ValueFromPipeline = $False, + ValueFromPipelineByPropertyName = $False + )] [ValidateRange(0, 100)] [double]$MinimumCoverage = 80 )