diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 280a601..fd4848b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,7 +1,7 @@ name: CI # Self-validation for NWarila/powershell-template. Lints workflows -# (actionlint), runs PSScriptAnalyzer with the PSGallery ruleset, and executes +# (actionlint), runs PSScriptAnalyzer with the house settings, and executes # the Pester v5 suite with NUnit output and a coverage target. Designed to be # green out of the box so consumers inherit a passing baseline. @@ -58,7 +58,7 @@ jobs: - name: Run PSScriptAnalyzer shell: pwsh run: | - $results = Invoke-ScriptAnalyzer -Path . -Settings PSGallery -Recurse + $results = Invoke-ScriptAnalyzer -Path . -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse if ($results) { $results | Format-Table -AutoSize | Out-String | Write-Host $errors = @($results | Where-Object { $_.Severity -eq 'Error' }) diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index bfa96db..7f3e31b 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -18,6 +18,7 @@ IncludeRules = @('*') ExcludeRules = @( + 'PSUseCorrectCasing' 'PSUseShouldProcessForStateChangingFunctions' ) @@ -60,10 +61,6 @@ CheckHashtable = $true } - PSUseCorrectCasing = @{ - Enable = $true - } - 'Measure-PrivateVariableDeclaration' = @{ Enable = $true } @@ -76,6 +73,10 @@ Enable = $true } + 'Measure-CanonicalNamedBlock' = @{ + Enable = $true + } + 'Measure-ExplicitCmdletBinding' = @{ Enable = $true } @@ -84,6 +85,10 @@ Enable = $true } + 'Measure-CanonicalKeywordCasing' = @{ + Enable = $true + } + 'Measure-NoRemoveVariableCleanup' = @{ Enable = $true } @@ -91,5 +96,9 @@ 'Measure-NoNewVariableDeclaration' = @{ Enable = $true } + + 'Measure-SoftReturn' = @{ + Enable = $true + } } } diff --git a/README.md b/README.md index e57d7a8..e9fede7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/NWarila/powershell-template/actions/workflows/ci.yaml/badge.svg)](https://github.com/NWarila/powershell-template/actions/workflows/ci.yaml) [![PowerShell 5.1+ / 7+](https://img.shields.io/badge/PowerShell-5.1%2B%20%7C%207%2B-5391FE?logo=powershell&logoColor=white)](https://learn.microsoft.com/powershell/) -[![PSScriptAnalyzer](https://img.shields.io/badge/lint-PSScriptAnalyzer-blue)](https://github.com/PowerShell/PSScriptAnalyzer) +[![PSScriptAnalyzer](https://img.shields.io/badge/lint-house%20PSScriptAnalyzer-blue)](PSScriptAnalyzerSettings.psd1) [![Tested with Pester](https://img.shields.io/badge/tested%20with-Pester%20v5-green)](https://pester.dev/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) @@ -17,8 +17,8 @@ rather than a blank canvas. (exported) and `Private/` (internal), one per file, with an explicit `FunctionsToExport` list — no wildcard exports. - **Publish-ready.** The manifest satisfies the PowerShell Gallery's - requirements and the PSGallery PSScriptAnalyzer ruleset, so a module can be - published without rework. + requirements, and the house PSScriptAnalyzer settings keep the module + reviewable without rework. - **Cross-edition.** Declares `CompatiblePSEditions = @('Core', 'Desktop')` with a 5.1 floor; CI exercises PowerShell 7 (Core) on Ubuntu. - **Safe CI.** Least-privilege permissions, SHA-pinned actions, per-job @@ -54,7 +54,7 @@ Then edit: ### 3. Validate locally ```bash -pwsh -c "Invoke-ScriptAnalyzer -Path . -Settings PSGallery -Recurse" +pwsh -c "Invoke-ScriptAnalyzer -Path . -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse" pwsh -File tests/Invoke-Tests.ps1 ``` @@ -86,7 +86,7 @@ The [CI workflow](.github/workflows/ci.yaml) runs on every push and pull request | Job | What it does | | ---------------- | ------------------------------------------------------------ | | actionlint | Lints GitHub Actions workflows | -| PSScriptAnalyzer | `Invoke-ScriptAnalyzer -Settings PSGallery -Recurse` | +| PSScriptAnalyzer | `Invoke-ScriptAnalyzer -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse` | | Pester | Pester v5 suite, NUnit output, coverage target (≥ 80%) | ## Documentation diff --git a/analyzers/HouseRules.psm1 b/analyzers/HouseRules.psm1 index c32b05a..7e778ad 100644 --- a/analyzers/HouseRules.psm1 +++ b/analyzers/HouseRules.psm1 @@ -1,6 +1,6 @@ #Requires -Version 5.1 -function ConvertTo-HouseRuleDiagnosticRecord { +Function ConvertTo-HouseRuleDiagnosticRecord { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -10,7 +10,7 @@ function ConvertTo-HouseRuleDiagnosticRecord { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.IScriptExtent] $Extent, @@ -36,7 +36,7 @@ function ConvertTo-HouseRuleDiagnosticRecord { } -function Get-HouseRuleFunctionAst { +Function Get-HouseRuleFunctionAst { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -46,7 +46,7 @@ function Get-HouseRuleFunctionAst { SupportsShouldProcess = $False )] [OutputType([System.Management.Automation.Language.FunctionDefinitionAst[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst @@ -55,7 +55,7 @@ function Get-HouseRuleFunctionAst { [System.Management.Automation.Language.FunctionDefinitionAst[]]@( $ScriptBlockAst.FindAll( { - param ( + Param ( [System.Management.Automation.Language.Ast] $Ast ) @@ -68,7 +68,7 @@ function Get-HouseRuleFunctionAst { } -function Test-HouseRuleAstBelongsToFunction { +Function Test-HouseRuleAstBelongsToFunction { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -78,7 +78,7 @@ function Test-HouseRuleAstBelongsToFunction { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.Ast] $Ast, @@ -89,20 +89,21 @@ function Test-HouseRuleAstBelongsToFunction { ) $Private:ParentAst = $Ast + [System.Boolean]$Private:BelongsToFunction = $False - while ($Null -ne $ParentAst) { - if ($ParentAst -is [System.Management.Automation.Language.FunctionDefinitionAst]) { - return [System.Boolean]([System.Object]::ReferenceEquals($ParentAst, $FunctionAst)) + While ($Null -ne $ParentAst -and $BelongsToFunction -eq $False) { + If ($ParentAst -is [System.Management.Automation.Language.FunctionDefinitionAst]) { + $BelongsToFunction = [System.Boolean]([System.Object]::ReferenceEquals($ParentAst, $FunctionAst)) + } Else { + $ParentAst = $ParentAst.Parent } - - $ParentAst = $ParentAst.Parent } - [System.Boolean]$False + [System.Boolean]$BelongsToFunction } -function Get-HouseRuleVariableName { +Function Get-HouseRuleVariableName { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -112,29 +113,24 @@ function Get-HouseRuleVariableName { SupportsShouldProcess = $False )] [OutputType([System.String])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.VariableExpressionAst] $VariableAst ) - if ($VariableAst.VariablePath.IsDriveQualified -eq $True) { - return - } - - if ($VariableAst.VariablePath.IsVariable -eq $False) { - return + If ( + $VariableAst.VariablePath.IsDriveQualified -eq $False -and + $VariableAst.VariablePath.IsVariable -eq $True -and + $VariableAst.VariablePath.IsScript -eq $False -and + $VariableAst.VariablePath.IsGlobal -eq $False + ) { + [System.String]($VariableAst.VariablePath.UserPath -replace '(?i)^(private|local):', '') } - if ($VariableAst.VariablePath.IsScript -eq $True -or $VariableAst.VariablePath.IsGlobal -eq $True) { - return - } - - [System.String]($VariableAst.VariablePath.UserPath -replace '(?i)^(private|local):', '') - } -function Test-HouseRuleAutomaticVariable { +Function Test-HouseRuleAutomaticVariable { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -144,7 +140,7 @@ function Test-HouseRuleAutomaticVariable { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.String] $Name @@ -207,7 +203,7 @@ function Test-HouseRuleAutomaticVariable { } -function Get-HouseRuleParameterName { +Function Get-HouseRuleParameterName { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -217,14 +213,14 @@ function Get-HouseRuleParameterName { SupportsShouldProcess = $False )] [OutputType([System.String[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) [System.String[]]@( - if ($Null -ne $FunctionAst.Body.ParamBlock) { + If ($Null -ne $FunctionAst.Body.ParamBlock) { $FunctionAst.Body.ParamBlock.Parameters | ForEach-Object -Process { Get-HouseRuleVariableName -VariableAst $PSItem.Name @@ -234,7 +230,7 @@ function Get-HouseRuleParameterName { } -function Get-HouseRuleIteratorName { +Function Get-HouseRuleIteratorName { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -244,7 +240,7 @@ function Get-HouseRuleIteratorName { SupportsShouldProcess = $False )] [OutputType([System.String[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst @@ -256,7 +252,7 @@ function Get-HouseRuleIteratorName { $FunctionAst.Body.FindAll( { - param ( + Param ( [System.Management.Automation.Language.Ast] $Ast ) @@ -268,40 +264,39 @@ function Get-HouseRuleIteratorName { ) | Where-Object -FilterScript { Test-HouseRuleAstBelongsToFunction -Ast $PSItem -FunctionAst $FunctionAst } | ForEach-Object -Process { - if ($PSItem -is [System.Management.Automation.Language.ForEachStatementAst]) { + If ($PSItem -is [System.Management.Automation.Language.ForEachStatementAst]) { [void]$Names.Add((Get-HouseRuleVariableName -VariableAst $PSItem.Variable)) - return - } - - if ($Null -ne $PSItem.Initializer) { - $PSItem.Initializer.FindAll( - { - param ( - [System.Management.Automation.Language.Ast] - $Ast - ) - - $Ast -is [System.Management.Automation.Language.VariableExpressionAst] - }, - $False - ) | ForEach-Object -Process { - [void]$Names.Add((Get-HouseRuleVariableName -VariableAst $PSItem)) + } Else { + If ($Null -ne $PSItem.Initializer) { + $PSItem.Initializer.FindAll( + { + Param ( + [System.Management.Automation.Language.Ast] + $Ast + ) + + $Ast -is [System.Management.Automation.Language.VariableExpressionAst] + }, + $False + ) | ForEach-Object -Process { + [void]$Names.Add((Get-HouseRuleVariableName -VariableAst $PSItem)) + } } - } - - if ($Null -ne $PSItem.Iterator) { - $PSItem.Iterator.FindAll( - { - param ( - [System.Management.Automation.Language.Ast] - $Ast - ) - $Ast -is [System.Management.Automation.Language.VariableExpressionAst] - }, - $False - ) | ForEach-Object -Process { - [void]$Names.Add((Get-HouseRuleVariableName -VariableAst $PSItem)) + If ($Null -ne $PSItem.Iterator) { + $PSItem.Iterator.FindAll( + { + Param ( + [System.Management.Automation.Language.Ast] + $Ast + ) + + $Ast -is [System.Management.Automation.Language.VariableExpressionAst] + }, + $False + ) | ForEach-Object -Process { + [void]$Names.Add((Get-HouseRuleVariableName -VariableAst $PSItem)) + } } } } @@ -310,7 +305,7 @@ function Get-HouseRuleIteratorName { } -function Get-HouseRuleStaticString { +Function Get-HouseRuleStaticString { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -320,36 +315,28 @@ function Get-HouseRuleStaticString { SupportsShouldProcess = $False )] [OutputType([System.String[]])] - param ( + Param ( [Parameter()] [AllowNull()] [System.Management.Automation.Language.Ast] $Ast ) - if ($Null -eq $Ast) { - return - } - - if ($Ast -is [System.Management.Automation.Language.StringConstantExpressionAst]) { - [System.String]$Ast.Value - return - } - - if ($Ast -is [System.Management.Automation.Language.ConstantExpressionAst]) { - [System.String]$Ast.Value - return - } - - if ($Ast -is [System.Management.Automation.Language.ArrayLiteralAst]) { - $Ast.Elements | ForEach-Object -Process { - Get-HouseRuleStaticString -Ast $PSItem + If ($Null -ne $Ast) { + If ($Ast -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + [System.String]$Ast.Value + } ElseIf ($Ast -is [System.Management.Automation.Language.ConstantExpressionAst]) { + [System.String]$Ast.Value + } ElseIf ($Ast -is [System.Management.Automation.Language.ArrayLiteralAst]) { + $Ast.Elements | ForEach-Object -Process { + Get-HouseRuleStaticString -Ast $PSItem + } } } } -function Get-HouseRuleCommandArgument { +Function Get-HouseRuleCommandArgument { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -359,7 +346,7 @@ function Get-HouseRuleCommandArgument { SupportsShouldProcess = $False )] [OutputType([System.Management.Automation.Language.Ast[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.CommandAst] $CommandAst, @@ -369,25 +356,25 @@ function Get-HouseRuleCommandArgument { $ParameterName ) - for ($Index = 1; $Index -lt $CommandAst.CommandElements.Count; $Index++) { + For ($Index = 1; $Index -lt $CommandAst.CommandElements.Count; $Index++) { $Private:Element = $CommandAst.CommandElements[$Index] - if ($Element -isnot [System.Management.Automation.Language.CommandParameterAst]) { - continue + If ($Element -isnot [System.Management.Automation.Language.CommandParameterAst]) { + Continue } - if ($Element.ParameterName -ine $ParameterName) { - continue + If ($Element.ParameterName -ine $ParameterName) { + Continue } - if ($Null -ne $Element.Argument) { + If ($Null -ne $Element.Argument) { $Element.Argument - continue + Continue } - if (($Index + 1) -lt $CommandAst.CommandElements.Count) { + If (($Index + 1) -lt $CommandAst.CommandElements.Count) { $Private:NextElement = $CommandAst.CommandElements[$Index + 1] - if ($NextElement -isnot [System.Management.Automation.Language.CommandParameterAst]) { + If ($NextElement -isnot [System.Management.Automation.Language.CommandParameterAst]) { $NextElement } } @@ -395,7 +382,7 @@ function Get-HouseRuleCommandArgument { } -function Get-HouseRuleCommandArgumentString { +Function Get-HouseRuleCommandArgumentString { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -405,7 +392,7 @@ function Get-HouseRuleCommandArgumentString { SupportsShouldProcess = $False )] [OutputType([System.String[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.CommandAst] $CommandAst, @@ -422,7 +409,7 @@ function Get-HouseRuleCommandArgumentString { } -function Test-HouseRuleCommandHasPrivateOption { +Function Test-HouseRuleCommandHasPrivateOption { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -432,7 +419,7 @@ function Test-HouseRuleCommandHasPrivateOption { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.CommandAst] $CommandAst @@ -446,7 +433,7 @@ function Test-HouseRuleCommandHasPrivateOption { } -function Test-HouseRuleCommandUsesNonLocalScope { +Function Test-HouseRuleCommandUsesNonLocalScope { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -456,7 +443,7 @@ function Test-HouseRuleCommandUsesNonLocalScope { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.CommandAst] $CommandAst @@ -470,7 +457,7 @@ function Test-HouseRuleCommandUsesNonLocalScope { } -function Get-HouseRuleAssignedExpressionVariable { +Function Get-HouseRuleAssignedExpressionVariable { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -480,32 +467,23 @@ function Get-HouseRuleAssignedExpressionVariable { SupportsShouldProcess = $False )] [OutputType([PSCustomObject[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.Ast] $Ast ) - if ($Ast -is [System.Management.Automation.Language.VariableExpressionAst]) { + If ($Ast -is [System.Management.Automation.Language.VariableExpressionAst]) { [PSCustomObject]@{ Name = Get-HouseRuleVariableName -VariableAst $Ast Extent = $Ast.Extent IsPrivate = [System.Boolean]$Ast.VariablePath.IsPrivate } - return - } - - if ($Ast -is [System.Management.Automation.Language.ConvertExpressionAst]) { + } ElseIf ($Ast -is [System.Management.Automation.Language.ConvertExpressionAst]) { Get-HouseRuleAssignedExpressionVariable -Ast $Ast.Child - return - } - - if ($Ast -is [System.Management.Automation.Language.AttributedExpressionAst]) { + } ElseIf ($Ast -is [System.Management.Automation.Language.AttributedExpressionAst]) { Get-HouseRuleAssignedExpressionVariable -Ast $Ast.Child - return - } - - if ($Ast -is [System.Management.Automation.Language.ArrayLiteralAst]) { + } ElseIf ($Ast -is [System.Management.Automation.Language.ArrayLiteralAst]) { $Ast.Elements | ForEach-Object -Process { Get-HouseRuleAssignedExpressionVariable -Ast $PSItem } @@ -513,7 +491,7 @@ function Get-HouseRuleAssignedExpressionVariable { } -function Get-HouseRuleAssignedVariable { +Function Get-HouseRuleAssignedVariable { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -523,7 +501,7 @@ function Get-HouseRuleAssignedVariable { SupportsShouldProcess = $False )] [OutputType([PSCustomObject[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst @@ -531,7 +509,7 @@ function Get-HouseRuleAssignedVariable { $FunctionAst.Body.FindAll( { - param ( + Param ( [System.Management.Automation.Language.Ast] $Ast ) @@ -544,38 +522,32 @@ function Get-HouseRuleAssignedVariable { ) | Where-Object -FilterScript { Test-HouseRuleAstBelongsToFunction -Ast $PSItem -FunctionAst $FunctionAst } | ForEach-Object -Process { - if ($PSItem -is [System.Management.Automation.Language.AssignmentStatementAst]) { + If ($PSItem -is [System.Management.Automation.Language.AssignmentStatementAst]) { Get-HouseRuleAssignedExpressionVariable -Ast $PSItem.Left - return - } - - if ($PSItem -is [System.Management.Automation.Language.UnaryExpressionAst]) { + } ElseIf ($PSItem -is [System.Management.Automation.Language.UnaryExpressionAst]) { Get-HouseRuleAssignedExpressionVariable -Ast $PSItem.Child - return - } - - $Private:CommandAst = $PSItem - $Private:CommandName = $CommandAst.GetCommandName() - if ($CommandName -inotmatch '^(New|Set)-Variable$') { - return - } - - if (Test-HouseRuleCommandUsesNonLocalScope -CommandAst $CommandAst) { - return - } - - $Private:Names = [System.String[]]@( - Get-HouseRuleCommandArgumentString -CommandAst $CommandAst -ParameterName 'Name' - ) - - $Names | ForEach-Object -Process { - [PSCustomObject]@{ - Name = [System.String]$PSItem - Extent = $CommandAst.Extent - IsPrivate = [System.Boolean]( - $CommandName -ieq 'New-Variable' -and - (Test-HouseRuleCommandHasPrivateOption -CommandAst $CommandAst) + } Else { + $Private:CommandAst = $PSItem + $Private:CommandName = $CommandAst.GetCommandName() + + If ( + $CommandName -imatch '^(New|Set)-Variable$' -and + (Test-HouseRuleCommandUsesNonLocalScope -CommandAst $CommandAst) -eq $False + ) { + $Private:Names = [System.String[]]@( + Get-HouseRuleCommandArgumentString -CommandAst $CommandAst -ParameterName 'Name' ) + + $Names | ForEach-Object -Process { + [PSCustomObject]@{ + Name = [System.String]$PSItem + Extent = $CommandAst.Extent + IsPrivate = [System.Boolean]( + $CommandName -ieq 'New-Variable' -and + (Test-HouseRuleCommandHasPrivateOption -CommandAst $CommandAst) + ) + } + } } } } | Where-Object -FilterScript { @@ -584,7 +556,7 @@ function Get-HouseRuleAssignedVariable { } -function Get-HouseRulePrivateDeclarationName { +Function Get-HouseRulePrivateDeclarationName { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -594,7 +566,7 @@ function Get-HouseRulePrivateDeclarationName { SupportsShouldProcess = $False )] [OutputType([System.String[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst, @@ -605,47 +577,50 @@ function Get-HouseRulePrivateDeclarationName { $SearchAst = $Null ) - if ($Null -eq $SearchAst) { + If ($Null -eq $SearchAst) { $SearchAst = $FunctionAst.Body } - [System.String[]]@( - $SearchAst.FindAll( - { - param ( - [System.Management.Automation.Language.Ast] - $Ast - ) - - $Ast -is [System.Management.Automation.Language.AssignmentStatementAst] -or - $Ast -is [System.Management.Automation.Language.CommandAst] - }, - $True - ) | Where-Object -FilterScript { - Test-HouseRuleAstBelongsToFunction -Ast $PSItem -FunctionAst $FunctionAst - } | ForEach-Object -Process { - if ($PSItem -is [System.Management.Automation.Language.AssignmentStatementAst]) { - Get-HouseRuleAssignedExpressionVariable -Ast $PSItem.Left | - Where-Object -FilterScript { $PSItem.IsPrivate -eq $True } | - ForEach-Object -Process { $PSItem.Name } - return - } + $Private:Names = [System.Collections.Generic.List[System.String]]::new() - if ($PSItem.GetCommandName() -inotmatch '^New-Variable$') { - return - } + $SearchAst.FindAll( + { + Param ( + [System.Management.Automation.Language.Ast] + $Ast + ) - if ((Test-HouseRuleCommandHasPrivateOption -CommandAst $PSItem) -eq $False) { - return + $Ast -is [System.Management.Automation.Language.AssignmentStatementAst] -or + $Ast -is [System.Management.Automation.Language.CommandAst] + }, + $True + ) | Where-Object -FilterScript { + Test-HouseRuleAstBelongsToFunction -Ast $PSItem -FunctionAst $FunctionAst + } | ForEach-Object -Process { + If ($PSItem -is [System.Management.Automation.Language.AssignmentStatementAst]) { + ForEach ($AssignedVariable In Get-HouseRuleAssignedExpressionVariable -Ast $PSItem.Left) { + If ($AssignedVariable.IsPrivate -eq $True) { + [void]$Names.Add($AssignedVariable.Name) } + } + } - Get-HouseRuleCommandArgumentString -CommandAst $PSItem -ParameterName 'Name' + If ( + $PSItem -is [System.Management.Automation.Language.CommandAst] -and + $PSItem.GetCommandName() -imatch '^New-Variable$' -and + (Test-HouseRuleCommandHasPrivateOption -CommandAst $PSItem) -eq $True + ) { + ForEach ($CommandArgumentName In Get-HouseRuleCommandArgumentString -CommandAst $PSItem -ParameterName 'Name') { + [void]$Names.Add($CommandArgumentName) } - ) + } + } + + [System.String[]]$Names.ToArray() } -function Test-HouseRulePipelineParameter { +Function Test-HouseRulePipelineParameter { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -655,55 +630,53 @@ function Test-HouseRulePipelineParameter { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) - if ($Null -eq $FunctionAst.Body.ParamBlock) { - return [System.Boolean]$False - } - - foreach ($ParameterAst in $FunctionAst.Body.ParamBlock.Parameters) { - foreach ($AttributeAst in $ParameterAst.Attributes) { - if ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { - continue - } + [System.Boolean]$Private:HasPipelineParameter = $False - if ($AttributeAst.TypeName.FullName -ine 'Parameter') { - continue - } - - foreach ($NamedArgument in $AttributeAst.NamedArguments) { - if ($NamedArgument.ArgumentName -inotmatch '^ValueFromPipeline(ByPropertyName)?$') { - continue + If ($Null -ne $FunctionAst.Body.ParamBlock) { + ForEach ($ParameterAst In $FunctionAst.Body.ParamBlock.Parameters) { + ForEach ($AttributeAst In $ParameterAst.Attributes) { + If ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { + Continue } - if ($Null -eq $NamedArgument.Argument) { - return [System.Boolean]$True + If ($AttributeAst.TypeName.FullName -ine 'Parameter') { + Continue } - if ($NamedArgument.Argument.Extent.Text -ieq $NamedArgument.ArgumentName) { - return [System.Boolean]$True - } + ForEach ($NamedArgument In $AttributeAst.NamedArguments) { + If ($NamedArgument.ArgumentName -inotmatch '^ValueFromPipeline(ByPropertyName)?$') { + Continue + } - try { - if ($NamedArgument.Argument.SafeGetValue() -eq $True) { - return [System.Boolean]$True + If ($Null -eq $NamedArgument.Argument) { + $HasPipelineParameter = $True + } ElseIf ($NamedArgument.Argument.Extent.Text -ieq $NamedArgument.ArgumentName) { + $HasPipelineParameter = $True + } Else { + Try { + If ($NamedArgument.Argument.SafeGetValue() -eq $True) { + $HasPipelineParameter = $True + } + } Catch { + Continue + } } - } catch { - continue } } } } - [System.Boolean]$False + [System.Boolean]$HasPipelineParameter } -function Test-HouseRuleNamedBlock { +Function Test-HouseRuleNamedBlock { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -713,33 +686,62 @@ function Test-HouseRuleNamedBlock { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.FunctionDefinitionAst] + $FunctionAst + ) + + [System.Boolean]( + $Null -ne $FunctionAst.Body.DynamicParamBlock -or + $Null -ne $FunctionAst.Body.BeginBlock -or + $Null -ne $FunctionAst.Body.ProcessBlock -or + ($Null -ne $FunctionAst.Body.EndBlock -and $FunctionAst.Body.EndBlock.Unnamed -eq $False) + ) + +} + +Function Get-HouseRuleNamedBlockAst { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Management.Automation.Language.NamedBlockAst[]])] + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) - if ($Null -ne $FunctionAst.Body.DynamicParamBlock) { - return [System.Boolean]$True + [System.Collections.Generic.List[System.Management.Automation.Language.NamedBlockAst]]$Private:Blocks = [System.Collections.Generic.List[System.Management.Automation.Language.NamedBlockAst]]::new() + + If ($Null -ne $FunctionAst.Body.DynamicParamBlock) { + [void]$Blocks.Add($FunctionAst.Body.DynamicParamBlock) } - if ($Null -ne $FunctionAst.Body.BeginBlock) { - return [System.Boolean]$True + If ($Null -ne $FunctionAst.Body.BeginBlock) { + [void]$Blocks.Add($FunctionAst.Body.BeginBlock) } - if ($Null -ne $FunctionAst.Body.ProcessBlock) { - return [System.Boolean]$True + If ($Null -ne $FunctionAst.Body.ProcessBlock) { + [void]$Blocks.Add($FunctionAst.Body.ProcessBlock) } - if ($Null -ne $FunctionAst.Body.EndBlock -and $FunctionAst.Body.EndBlock.Unnamed -eq $False) { - return [System.Boolean]$True + If ($Null -ne $FunctionAst.Body.EndBlock -and $FunctionAst.Body.EndBlock.Unnamed -eq $False) { + [void]$Blocks.Add($FunctionAst.Body.EndBlock) } - [System.Boolean]$False + [System.Management.Automation.Language.NamedBlockAst[]]@( + $Blocks | Sort-Object -Property { $PSItem.Extent.StartOffset } + ) } -function Get-HouseRuleFunctionAttribute { +Function Get-HouseRuleFunctionAttribute { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -749,7 +751,7 @@ function Get-HouseRuleFunctionAttribute { SupportsShouldProcess = $False )] [OutputType([System.Management.Automation.Language.AttributeAst[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [ValidateNotNullOrEmpty()] [System.String] @@ -760,26 +762,24 @@ function Get-HouseRuleFunctionAttribute { $FunctionAst ) - if ($Null -eq $FunctionAst.Body.ParamBlock) { - return - } - - $FunctionAst.Body.ParamBlock.Attributes | - Where-Object -FilterScript { - $PSItem -is [System.Management.Automation.Language.AttributeAst] -and - ( + If ($Null -ne $FunctionAst.Body.ParamBlock) { + $FunctionAst.Body.ParamBlock.Attributes | + Where-Object -FilterScript { + $PSItem -is [System.Management.Automation.Language.AttributeAst] -and ( - ([System.String]$PSItem.TypeName.FullName) -replace - '^(System\.Management\.Automation\.)?', + ( + ([System.String]$PSItem.TypeName.FullName) -replace + '^(System\.Management\.Automation\.)?', + '' + ) -replace 'Attribute$', '' - ) -replace 'Attribute$', - '' - ) -ieq $AttributeName - } + ) -ieq $AttributeName + } + } } -function Get-HouseRuleAttributeName { +Function Get-HouseRuleAttributeName { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -789,7 +789,7 @@ function Get-HouseRuleAttributeName { SupportsShouldProcess = $False )] [OutputType([System.String])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.AttributeAst] $AttributeAst @@ -806,7 +806,7 @@ function Get-HouseRuleAttributeName { } -function Get-HouseRuleParameterAttributeOrderKey { +Function Get-HouseRuleParameterAttributeOrderKey { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -816,35 +816,33 @@ function Get-HouseRuleParameterAttributeOrderKey { SupportsShouldProcess = $False )] [OutputType([System.String])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.AttributeBaseAst] $AttributeAst ) - if ($AttributeAst -is [System.Management.Automation.Language.TypeConstraintAst]) { - return [System.String]'3|' - } - - if ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { - return [System.String]'2|' - } + [System.String]$Private:OrderKey = '2|' - $Private:AttributeName = Get-HouseRuleAttributeName -AttributeAst $AttributeAst + If ($AttributeAst -is [System.Management.Automation.Language.TypeConstraintAst]) { + $OrderKey = '3|' + } ElseIf ($AttributeAst -is [System.Management.Automation.Language.AttributeAst]) { + $Private:AttributeName = Get-HouseRuleAttributeName -AttributeAst $AttributeAst - if ($AttributeName -ieq 'Parameter') { - return [System.String]'0|Parameter' - } - - if ($AttributeName -ieq 'Alias') { - return [System.String]'1|Alias' + If ($AttributeName -ieq 'Parameter') { + $OrderKey = '0|Parameter' + } ElseIf ($AttributeName -ieq 'Alias') { + $OrderKey = '1|Alias' + } Else { + $OrderKey = [System.String]('2|{0}' -f $AttributeName) + } } - [System.String]('2|{0}' -f $AttributeName) + [System.String]$OrderKey } -function Test-HouseRuleAlphabeticalOrder { +Function Test-HouseRuleAlphabeticalOrder { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -854,24 +852,26 @@ function Test-HouseRuleAlphabeticalOrder { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [AllowEmptyCollection()] [System.String[]] $Value ) - for ($Index = 1; $Index -lt $Value.Count; $Index++) { - if ([System.StringComparer]::OrdinalIgnoreCase.Compare($Value[$Index - 1], $Value[$Index]) -gt 0) { - return [System.Boolean]$False + [System.Boolean]$Private:IsOrdered = $True + + For ($Index = 1; $Index -lt $Value.Count; $Index++) { + If ([System.StringComparer]::OrdinalIgnoreCase.Compare($Value[$Index - 1], $Value[$Index]) -gt 0) { + $IsOrdered = $False } } - [System.Boolean]$True + [System.Boolean]$IsOrdered } -function Test-HouseRuleNamedArgumentValueIsTrue { +Function Test-HouseRuleNamedArgumentValueIsTrue { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -881,29 +881,99 @@ function Test-HouseRuleNamedArgumentValueIsTrue { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.NamedAttributeArgumentAst] $NamedArgument ) - if ($Null -eq $NamedArgument.Argument) { - return [System.Boolean]$True + [System.Boolean]$Private:IsTrue = $False + + If ($Null -eq $NamedArgument.Argument) { + $IsTrue = $True + } ElseIf ($NamedArgument.Argument.Extent.Text -ieq $NamedArgument.ArgumentName) { + $IsTrue = $True + } Else { + Try { + $IsTrue = [System.Boolean]$NamedArgument.Argument.SafeGetValue() + } Catch { + $IsTrue = $False + } } - if ($NamedArgument.Argument.Extent.Text -ieq $NamedArgument.ArgumentName) { - return [System.Boolean]$True + [System.Boolean]$IsTrue + +} + +Function Get-HouseRuleNamedAttributeArgument { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Management.Automation.Language.NamedAttributeArgumentAst])] + Param ( + [Parameter(Mandatory = $True)] + [System.String] + $ArgumentName, + + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.AttributeAst] + $AttributeAst + ) + + [System.Management.Automation.Language.NamedAttributeArgumentAst]$Private:MatchedArgument = $Null + + ForEach ($NamedArgument In $AttributeAst.NamedArguments) { + If ($NamedArgument.ArgumentName -ieq $ArgumentName -and $Null -eq $MatchedArgument) { + $MatchedArgument = [System.Management.Automation.Language.NamedAttributeArgumentAst]$NamedArgument + } } - try { - return [System.Boolean]$NamedArgument.Argument.SafeGetValue() - } catch { - return [System.Boolean]$False + $MatchedArgument + +} + +Function Test-HouseRuleNamedArgumentValueEqual { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + Param ( + [Parameter(Mandatory = $True)] + [AllowNull()] + [System.Object] + $ExpectedValue, + + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.NamedAttributeArgumentAst] + $NamedArgument + ) + + [System.Boolean]$Private:IsEqual = $False + + If ($Null -ne $NamedArgument.Argument) { + Try { + $Private:ActualValue = $NamedArgument.Argument.SafeGetValue() + $IsEqual = [System.Boolean]([System.Object]::Equals($ActualValue, $ExpectedValue)) + } Catch { + $IsEqual = $False + } } + [System.Boolean]$IsEqual + } -function Test-HouseRuleParameterAttributeOrder { +Function Test-HouseRuleParameterAttributeOrder { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -913,7 +983,7 @@ function Test-HouseRuleParameterAttributeOrder { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ParameterAst] $ParameterAst @@ -922,27 +992,28 @@ function Test-HouseRuleParameterAttributeOrder { # Initialize Variable(s) [System.String]$Private:CurrentKey = [System.String]::Empty [System.Boolean]$Private:HasPreviousKey = $False + [System.Boolean]$Private:IsOrdered = $True [System.String]$Private:PreviousKey = [System.String]::Empty - foreach ($AttributeAst in $ParameterAst.Attributes) { + ForEach ($AttributeAst In $ParameterAst.Attributes) { $CurrentKey = Get-HouseRuleParameterAttributeOrderKey -AttributeAst $AttributeAst - if ( + If ( $HasPreviousKey -eq $True -and [System.StringComparer]::OrdinalIgnoreCase.Compare($PreviousKey, $CurrentKey) -gt 0 ) { - return [System.Boolean]$False + $IsOrdered = $False } $HasPreviousKey = $True $PreviousKey = $CurrentKey } - [System.Boolean]$True + [System.Boolean]$IsOrdered } -function Test-HouseRuleParameterTypeLast { +Function Test-HouseRuleParameterTypeLast { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -952,33 +1023,34 @@ function Test-HouseRuleParameterTypeLast { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ParameterAst] $ParameterAst ) - $Private:HasSeenType = $False + [System.Boolean]$Private:HasSeenType = $False + [System.Boolean]$Private:IsTypeLast = $True - foreach ($AttributeAst in $ParameterAst.Attributes) { - if ($AttributeAst -is [System.Management.Automation.Language.TypeConstraintAst]) { + ForEach ($AttributeAst In $ParameterAst.Attributes) { + If ($AttributeAst -is [System.Management.Automation.Language.TypeConstraintAst]) { $HasSeenType = $True - continue + Continue } - if ( + If ( $HasSeenType -eq $True -and $AttributeAst -is [System.Management.Automation.Language.AttributeAst] ) { - return [System.Boolean]$False + $IsTypeLast = $False } } - [System.Boolean]$True + [System.Boolean]$IsTypeLast } -function Test-HouseRuleParameterOrderGuard { +Function Test-HouseRuleParameterOrderGuard { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -988,54 +1060,51 @@ function Test-HouseRuleParameterOrderGuard { SupportsShouldProcess = $False )] [OutputType([System.Boolean])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) - foreach ($CmdletBindingAttribute in Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'CmdletBinding') { - foreach ($NamedArgument in $CmdletBindingAttribute.NamedArguments) { - if ($NamedArgument.ArgumentName -ine 'PositionalBinding') { - continue + [System.Boolean]$Private:HasParameterOrderGuard = $False + + ForEach ($CmdletBindingAttribute In Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'CmdletBinding') { + ForEach ($NamedArgument In $CmdletBindingAttribute.NamedArguments) { + If ($NamedArgument.ArgumentName -ine 'PositionalBinding') { + Continue } - if ((Test-HouseRuleNamedArgumentValueIsTrue -NamedArgument $NamedArgument) -eq $True) { - return [System.Boolean]$True + If ((Test-HouseRuleNamedArgumentValueIsTrue -NamedArgument $NamedArgument) -eq $True) { + $HasParameterOrderGuard = $True } } } - if ($Null -eq $FunctionAst.Body.ParamBlock) { - return [System.Boolean]$False - } - - foreach ($ParameterAst in $FunctionAst.Body.ParamBlock.Parameters) { - foreach ($AttributeAst in $ParameterAst.Attributes) { - if ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { - continue - } + If ($Null -ne $FunctionAst.Body.ParamBlock) { + ForEach ($ParameterAst In $FunctionAst.Body.ParamBlock.Parameters) { + ForEach ($AttributeAst In $ParameterAst.Attributes) { + If ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { + Continue + } - if ((Get-HouseRuleAttributeName -AttributeAst $AttributeAst) -ine 'Parameter') { - continue - } + If ((Get-HouseRuleAttributeName -AttributeAst $AttributeAst) -ine 'Parameter') { + Continue + } - foreach ($NamedArgument in $AttributeAst.NamedArguments) { - if ( - $NamedArgument.ArgumentName -ieq 'Position' -or - $NamedArgument.ArgumentName -ieq 'ParameterSetName' - ) { - return [System.Boolean]$True + ForEach ($NamedArgument In $AttributeAst.NamedArguments) { + If ($NamedArgument.ArgumentName -ieq 'Position') { + $HasParameterOrderGuard = $True + } } } } } - [System.Boolean]$False + [System.Boolean]$HasParameterOrderGuard } -function Get-HouseRuleProcessResetVariableName { +Function Get-HouseRuleProcessResetVariableName { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -1045,38 +1114,49 @@ function Get-HouseRuleProcessResetVariableName { SupportsShouldProcess = $False )] [OutputType([System.String[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.NamedBlockAst] $ProcessBlock ) $Private:Names = [System.Collections.Generic.List[System.String]]::new() + [System.Boolean]$Private:CanSkipEnteringProcess = $True + + ForEach ($Statement In $ProcessBlock.Statements) { + If ( + $CanSkipEnteringProcess -eq $True -and + (Test-HouseRuleEnteringProcessDebugStatement -StatementAst $Statement) -eq $True + ) { + $CanSkipEnteringProcess = $False + Continue + } + + $CanSkipEnteringProcess = $False - foreach ($Statement in $ProcessBlock.Statements) { - if ($Statement -is [System.Management.Automation.Language.AssignmentStatementAst]) { + If ($Statement -is [System.Management.Automation.Language.AssignmentStatementAst]) { Get-HouseRuleAssignedExpressionVariable -Ast $Statement.Left | ForEach-Object -Process { [void]$Names.Add($PSItem.Name) } - continue + Continue } - if ($Statement -isnot [System.Management.Automation.Language.PipelineAst]) { - break + If ($Statement -isnot [System.Management.Automation.Language.PipelineAst]) { + Break } - if ($Statement.PipelineElements.Count -ne 1) { - break + If ($Statement.PipelineElements.Count -ne 1) { + Break } $Private:CommandAst = $Statement.PipelineElements[0] - if ($CommandAst -isnot [System.Management.Automation.Language.CommandAst]) { - break + If ($CommandAst -isnot [System.Management.Automation.Language.CommandAst]) { + Break } - if ($CommandAst.GetCommandName() -inotmatch '^Clear-Variable$') { - break + If ($CommandAst.GetCommandName() -inotmatch '^Clear-Variable$') { + Break } Get-HouseRuleCommandArgumentString -CommandAst $CommandAst -ParameterName 'Name' | @@ -1089,7 +1169,135 @@ function Get-HouseRuleProcessResetVariableName { } -function Measure-CanonicalAttributeOrder { +Function Test-HouseRuleEnteringProcessDebugStatement { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + Param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.StatementAst] + $StatementAst + ) + + [System.Boolean]$Private:IsEnteringProcessDebug = $False + + If ( + $StatementAst -is [System.Management.Automation.Language.PipelineAst] -and + $StatementAst.PipelineElements.Count -eq 1 + ) { + [System.Management.Automation.Language.CommandAst]$Private:CommandAst = $StatementAst.PipelineElements[0] -as [System.Management.Automation.Language.CommandAst] + If ($Null -ne $CommandAst -and $CommandAst.GetCommandName() -imatch '^Write-Debug$') { + ForEach ($Message In Get-HouseRuleCommandArgumentString -CommandAst $CommandAst -ParameterName 'Message') { + If ($Message -imatch '\]\s+Entering Process$') { + $IsEnteringProcessDebug = $True + } + } + } + } + + [System.Boolean]$IsEnteringProcessDebug + +} + +Function Test-HouseRuleExitingDebugStatement { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + Param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.StatementAst] + $StatementAst + ) + + [System.Boolean]$Private:IsExitingDebug = $False + + If ( + $StatementAst -is [System.Management.Automation.Language.PipelineAst] -and + $StatementAst.PipelineElements.Count -eq 1 + ) { + [System.Management.Automation.Language.CommandAst]$Private:CommandAst = $StatementAst.PipelineElements[0] -as [System.Management.Automation.Language.CommandAst] + If ($Null -ne $CommandAst -and $CommandAst.GetCommandName() -imatch '^Write-Debug$') { + ForEach ($Message In Get-HouseRuleCommandArgumentString -CommandAst $CommandAst -ParameterName 'Message') { + If ($Message -imatch '\]\s+Exiting(\s+.+)?$') { + $IsExitingDebug = $True + } + } + } + } + + [System.Boolean]$IsExitingDebug + +} + +Function Test-HouseRuleFunctionDeclaresPrivateResult { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + Param ( + [Parameter(Mandatory = $True)] + [System.Management.Automation.Language.FunctionDefinitionAst] + $FunctionAst + ) + + [System.Boolean]$Private:DeclaresPrivateResult = $False + + ForEach ($DeclarationName In Get-HouseRulePrivateDeclarationName -FunctionAst $FunctionAst) { + If ($DeclarationName -ieq 'Result') { + $DeclaresPrivateResult = $True + } + } + + [System.Boolean]$DeclaresPrivateResult + +} + +Function Test-HouseRuleLastStatementExitingDebug { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com/NWarila/powershell-template/blob/main/docs/README.md', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.Boolean])] + Param ( + [Parameter()] + [AllowNull()] + [System.Management.Automation.Language.NamedBlockAst] + $BlockAst + ) + + [System.Boolean]$Private:IsLastStatementExitingDebug = $False + + If ($Null -ne $BlockAst -and $BlockAst.Statements.Count -gt 0) { + [System.Management.Automation.Language.StatementAst]$Private:LastStatement = $BlockAst.Statements[$BlockAst.Statements.Count - 1] + $IsLastStatementExitingDebug = Test-HouseRuleExitingDebugStatement -StatementAst $LastStatement + } + + [System.Boolean]$IsLastStatementExitingDebug + +} + +Function Measure-CanonicalAttributeOrder { <# .SYNOPSIS Flags non-canonical ordering in function declaration attributes and parameters. @@ -1103,20 +1311,20 @@ function Measure-CanonicalAttributeOrder { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { - foreach ($CmdletBindingAttribute in Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'CmdletBinding') { + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + ForEach ($CmdletBindingAttribute In Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'CmdletBinding') { $Private:CmdletBindingOptionNames = [System.String[]]@( $CmdletBindingAttribute.NamedArguments | ForEach-Object -Process { $PSItem.ArgumentName } ) - if ((Test-HouseRuleAlphabeticalOrder -Value $CmdletBindingOptionNames) -eq $False) { + If ((Test-HouseRuleAlphabeticalOrder -Value $CmdletBindingOptionNames) -eq $False) { ConvertTo-HouseRuleDiagnosticRecord ` -RuleName 'Measure-CanonicalAttributeOrder' ` -Extent $CmdletBindingAttribute.Extent ` @@ -1127,18 +1335,18 @@ function Measure-CanonicalAttributeOrder { } } - if ($Null -eq $FunctionAst.Body.ParamBlock) { - continue + If ($Null -eq $FunctionAst.Body.ParamBlock) { + Continue } - foreach ($ParameterAst in $FunctionAst.Body.ParamBlock.Parameters) { - foreach ($AttributeAst in $ParameterAst.Attributes) { - if ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { - continue + ForEach ($ParameterAst In $FunctionAst.Body.ParamBlock.Parameters) { + ForEach ($AttributeAst In $ParameterAst.Attributes) { + If ($AttributeAst -isnot [System.Management.Automation.Language.AttributeAst]) { + Continue } - if ((Get-HouseRuleAttributeName -AttributeAst $AttributeAst) -ine 'Parameter') { - continue + If ((Get-HouseRuleAttributeName -AttributeAst $AttributeAst) -ine 'Parameter') { + Continue } $Private:ParameterArgumentNames = [System.String[]]@( @@ -1146,7 +1354,7 @@ function Measure-CanonicalAttributeOrder { ForEach-Object -Process { $PSItem.ArgumentName } ) - if ((Test-HouseRuleAlphabeticalOrder -Value $ParameterArgumentNames) -eq $False) { + If ((Test-HouseRuleAlphabeticalOrder -Value $ParameterArgumentNames) -eq $False) { ConvertTo-HouseRuleDiagnosticRecord ` -RuleName 'Measure-CanonicalAttributeOrder' ` -Extent $AttributeAst.Extent ` @@ -1156,9 +1364,25 @@ function Measure-CanonicalAttributeOrder { (Get-HouseRuleVariableName -VariableAst $ParameterAst.Name) ) } + + ForEach ($NamedArgument In $AttributeAst.NamedArguments) { + If ($NamedArgument.ArgumentName -ine 'Position') { + Continue + } + + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalAttributeOrder' ` + -Extent $NamedArgument.Extent ` + -Message ( + "Function '{0}' parameter '{1}' must not use Parameter(Position); PositionalBinding must stay false (SG-5e)." -f + $FunctionAst.Name, + (Get-HouseRuleVariableName -VariableAst $ParameterAst.Name) + ) + } + } - if ((Test-HouseRuleParameterTypeLast -ParameterAst $ParameterAst) -eq $False) { + If ((Test-HouseRuleParameterTypeLast -ParameterAst $ParameterAst) -eq $False) { ConvertTo-HouseRuleDiagnosticRecord ` -RuleName 'Measure-CanonicalAttributeOrder' ` -Extent $ParameterAst.Extent ` @@ -1169,7 +1393,7 @@ function Measure-CanonicalAttributeOrder { ) } - if ((Test-HouseRuleParameterAttributeOrder -ParameterAst $ParameterAst) -eq $False) { + If ((Test-HouseRuleParameterAttributeOrder -ParameterAst $ParameterAst) -eq $False) { ConvertTo-HouseRuleDiagnosticRecord ` -RuleName 'Measure-CanonicalAttributeOrder' ` -Extent $ParameterAst.Extent ` @@ -1181,13 +1405,9 @@ function Measure-CanonicalAttributeOrder { } } - if ((Test-HouseRuleParameterOrderGuard -FunctionAst $FunctionAst) -eq $True) { - continue - } - $Private:ParameterNames = [System.String[]]@(Get-HouseRuleParameterName -FunctionAst $FunctionAst) - if ((Test-HouseRuleAlphabeticalOrder -Value $ParameterNames) -eq $True) { - continue + If ((Test-HouseRuleAlphabeticalOrder -Value $ParameterNames) -eq $True) { + Continue } ConvertTo-HouseRuleDiagnosticRecord ` @@ -1201,7 +1421,122 @@ function Measure-CanonicalAttributeOrder { } -function Measure-ExplicitCmdletBinding { +Function Measure-CanonicalKeywordCasing { + <# + .SYNOPSIS + Flags PowerShell keywords that do not use the house canonical casing. + #> + [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(Mandatory = $True)] + [System.Management.Automation.Language.ScriptBlockAst] + $ScriptBlockAst + ) + + If ($Null -eq $ScriptBlockAst.Parent) { + $Private:CanonicalKeywords = [System.Collections.Generic.Dictionary[System.String, System.String]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + + [System.String[]]@( + 'Assembly', + 'Base', + 'Begin', + 'Break', + 'Catch', + 'Class', + 'Command', + 'Configuration', + 'Continue', + 'Data', + 'Define', + 'Do', + 'DynamicKeyword', + 'DynamicParam', + 'Else', + 'ElseIf', + 'End', + 'Enum', + 'Exit', + 'Filter', + 'Finally', + 'For', + 'ForEach', + 'From', + 'Function', + 'Hidden', + 'If', + 'In', + 'InlineScript', + 'Interface', + 'Module', + 'Namespace', + 'Param', + 'Parallel', + 'Private', + 'Process', + 'Public', + 'Return', + 'Sequence', + 'Static', + 'Switch', + 'Throw', + 'Trap', + 'Try', + 'Type', + 'Until', + 'Using', + 'Var', + 'While', + 'Workflow' + ) | ForEach-Object -Process { + $CanonicalKeywords.Add($PSItem, $PSItem) + } + + [System.Management.Automation.Language.Token[]]$Private:Tokens = @() + [System.Management.Automation.Language.ParseError[]]$Private:ParseErrors = @() + $Null = [System.Management.Automation.Language.Parser]::ParseInput( + $ScriptBlockAst.Extent.Text, + [ref]$Tokens, + [ref]$ParseErrors + ) + + ForEach ($Token In $Tokens) { + [System.Management.Automation.Language.TokenFlags]$Private:TokenTraits = ( + [System.Management.Automation.Language.TokenTraits]::GetTraits($Token.Kind) + ) + + If (($TokenTraits -band [System.Management.Automation.Language.TokenFlags]::Keyword) -eq 0) { + Continue + } + + If ($CanonicalKeywords.ContainsKey($Token.Text) -eq $False) { + Continue + } + + [System.String]$Private:ExpectedKeyword = $CanonicalKeywords[$Token.Text] + If ($Token.Text -ceq $ExpectedKeyword) { + Continue + } + + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalKeywordCasing' ` + -Extent $Token.Extent ` + -Message ("Keyword '{0}' must be canonical casing '{1}'." -f $Token.Text, $ExpectedKeyword) + } + } + +} + +Function Measure-ExplicitCmdletBinding { <# .SYNOPSIS Flags functions missing the house explicit CmdletBinding surface. @@ -1215,7 +1550,7 @@ function Measure-ExplicitCmdletBinding { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst @@ -1235,16 +1570,16 @@ function Measure-ExplicitCmdletBinding { ) [System.Collections.Generic.HashSet[System.String]]$Private:SeenOptionNames = $Null - foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { $CmdletBindingAttributes = [System.Management.Automation.Language.AttributeAst[]]@( Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'CmdletBinding' ) $CmdletBindingAttribute = $Null - if ($CmdletBindingAttributes.Count -gt 0) { + If ($CmdletBindingAttributes.Count -gt 0) { $CmdletBindingAttribute = $CmdletBindingAttributes[0] } - if ($Null -eq $CmdletBindingAttribute) { + If ($Null -eq $CmdletBindingAttribute) { ConvertTo-HouseRuleDiagnosticRecord ` -RuleName 'Measure-ExplicitCmdletBinding' ` -Extent $FunctionAst.Extent ` @@ -1252,17 +1587,17 @@ function Measure-ExplicitCmdletBinding { "Function '{0}' is missing the explicit CmdletBinding attribute required by SG-4." -f $FunctionAst.Name ) - } else { + } Else { $SeenOptionNames = [System.Collections.Generic.HashSet[System.String]]::new( [System.StringComparer]::OrdinalIgnoreCase ) - foreach ($NamedArgument in $CmdletBindingAttribute.NamedArguments) { + ForEach ($NamedArgument In $CmdletBindingAttribute.NamedArguments) { [void]$SeenOptionNames.Add($NamedArgument.ArgumentName) } - foreach ($RequiredOptionName in $RequiredOptionNames) { - if ($SeenOptionNames.Contains($RequiredOptionName) -eq $True) { - continue + ForEach ($RequiredOptionName In $RequiredOptionNames) { + If ($SeenOptionNames.Contains($RequiredOptionName) -eq $True) { + Continue } ConvertTo-HouseRuleDiagnosticRecord ` @@ -1274,6 +1609,25 @@ function Measure-ExplicitCmdletBinding { $RequiredOptionName ) } + + $Private:PositionalBindingArgument = Get-HouseRuleNamedAttributeArgument ` + -ArgumentName 'PositionalBinding' ` + -AttributeAst $CmdletBindingAttribute + + If ( + $Null -ne $PositionalBindingArgument -and + (Test-HouseRuleNamedArgumentValueEqual ` + -ExpectedValue $False ` + -NamedArgument $PositionalBindingArgument) -eq $False + ) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-ExplicitCmdletBinding' ` + -Extent $PositionalBindingArgument.Extent ` + -Message ( + "Function '{0}' CmdletBinding must set PositionalBinding = `$False required by SG-4." -f + $FunctionAst.Name + ) + } } $HasOutputType = [System.Boolean]( @@ -1281,8 +1635,8 @@ function Measure-ExplicitCmdletBinding { Get-HouseRuleFunctionAttribute -FunctionAst $FunctionAst -AttributeName 'OutputType' ).Count -gt 0 ) - if ($HasOutputType -eq $True) { - continue + If ($HasOutputType -eq $True) { + Continue } ConvertTo-HouseRuleDiagnosticRecord ` @@ -1296,7 +1650,7 @@ function Measure-ExplicitCmdletBinding { } -function Measure-PrivateVariableDeclaration { +Function Measure-PrivateVariableDeclaration { <# .SYNOPSIS Flags function-local assignments that are not Private-scoped. @@ -1310,13 +1664,13 @@ function Measure-PrivateVariableDeclaration { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { $Private:PrivateDeclarations = [System.Collections.Generic.HashSet[System.String]]::new( [System.StringComparer]::OrdinalIgnoreCase ) @@ -1334,17 +1688,17 @@ function Measure-PrivateVariableDeclaration { [void]$ExemptNames.Add($PSItem) } - foreach ($AssignedVariable in Get-HouseRuleAssignedVariable -FunctionAst $FunctionAst) { - if ($ExemptNames.Contains($AssignedVariable.Name)) { - continue + ForEach ($AssignedVariable In Get-HouseRuleAssignedVariable -FunctionAst $FunctionAst) { + If ($ExemptNames.Contains($AssignedVariable.Name)) { + Continue } - if (Test-HouseRuleAutomaticVariable -Name $AssignedVariable.Name) { - continue + If (Test-HouseRuleAutomaticVariable -Name $AssignedVariable.Name) { + Continue } - if ($AssignedVariable.IsPrivate -eq $True -or $PrivateDeclarations.Contains($AssignedVariable.Name)) { - continue + If ($AssignedVariable.IsPrivate -eq $True -or $PrivateDeclarations.Contains($AssignedVariable.Name)) { + Continue } ConvertTo-HouseRuleDiagnosticRecord ` @@ -1360,7 +1714,7 @@ function Measure-PrivateVariableDeclaration { } -function Measure-PipelineVariableLifecycle { +Function Measure-PipelineVariableLifecycle { <# .SYNOPSIS Flags pipeline function locals not declared in Begin or cleared at Process start. @@ -1374,19 +1728,19 @@ function Measure-PipelineVariableLifecycle { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { - if ((Test-HouseRulePipelineParameter -FunctionAst $FunctionAst) -eq $False) { - continue + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + If ((Test-HouseRulePipelineParameter -FunctionAst $FunctionAst) -eq $False) { + Continue } - if ($Null -eq $FunctionAst.Body.BeginBlock -or $Null -eq $FunctionAst.Body.ProcessBlock) { - continue + If ($Null -eq $FunctionAst.Body.BeginBlock -or $Null -eq $FunctionAst.Body.ProcessBlock) { + Continue } $Private:BeginDeclarations = [System.Collections.Generic.HashSet[System.String]]::new( @@ -1420,22 +1774,22 @@ function Measure-PipelineVariableLifecycle { System.Management.Automation.Language.IScriptExtent ]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($AssignedVariable in Get-HouseRuleAssignedVariable -FunctionAst $FunctionAst) { - if ($ExemptNames.Contains($AssignedVariable.Name)) { - continue + ForEach ($AssignedVariable In Get-HouseRuleAssignedVariable -FunctionAst $FunctionAst) { + If ($ExemptNames.Contains($AssignedVariable.Name)) { + Continue } - if (Test-HouseRuleAutomaticVariable -Name $AssignedVariable.Name) { - continue + If (Test-HouseRuleAutomaticVariable -Name $AssignedVariable.Name) { + Continue } - if (-not $AssignedNames.ContainsKey($AssignedVariable.Name)) { + If (-not $AssignedNames.ContainsKey($AssignedVariable.Name)) { $AssignedNames.Add($AssignedVariable.Name, $AssignedVariable.Extent) } } - foreach ($AssignedName in $AssignedNames.Keys) { - if ($BeginDeclarations.Contains($AssignedName) -eq $False) { + ForEach ($AssignedName In $AssignedNames.Keys) { + If ($BeginDeclarations.Contains($AssignedName) -eq $False) { ConvertTo-HouseRuleDiagnosticRecord ` -RuleName 'Measure-PipelineVariableLifecycle' ` -Extent $AssignedNames[$AssignedName] ` @@ -1447,9 +1801,9 @@ function Measure-PipelineVariableLifecycle { } } - foreach ($DeclaredName in $BeginDeclarations) { - if ($ResetNames.Contains($DeclaredName)) { - continue + ForEach ($DeclaredName In $BeginDeclarations) { + If ($ResetNames.Contains($DeclaredName)) { + Continue } ConvertTo-HouseRuleDiagnosticRecord ` @@ -1465,7 +1819,7 @@ function Measure-PipelineVariableLifecycle { } -function Measure-FlatNonPipelineFunction { +Function Measure-FlatNonPipelineFunction { <# .SYNOPSIS Flags named blocks on functions that do not accept pipeline input. @@ -1479,19 +1833,19 @@ function Measure-FlatNonPipelineFunction { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { - if ((Test-HouseRulePipelineParameter -FunctionAst $FunctionAst) -eq $True) { - continue + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + If ((Test-HouseRulePipelineParameter -FunctionAst $FunctionAst) -eq $True) { + Continue } - if ((Test-HouseRuleNamedBlock -FunctionAst $FunctionAst) -eq $False) { - continue + If ((Test-HouseRuleNamedBlock -FunctionAst $FunctionAst) -eq $False) { + Continue } ConvertTo-HouseRuleDiagnosticRecord ` @@ -1505,7 +1859,69 @@ function Measure-FlatNonPipelineFunction { } -function Measure-NoRemoveVariableCleanup { +Function Measure-CanonicalNamedBlock { + <# + .SYNOPSIS + Flags non-canonical named block casing and brace layout. + #> + [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(Mandatory = $True)] + [System.Management.Automation.Language.ScriptBlockAst] + $ScriptBlockAst + ) + + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + [System.Management.Automation.Language.NamedBlockAst[]]$Private:Blocks = @( + Get-HouseRuleNamedBlockAst -FunctionAst $FunctionAst + ) + + ForEach ($Block In $Blocks) { + [System.String]$Private:ExpectedHeader = '{0} {{' -f $Block.BlockKind.ToString() + [System.String]$Private:HeaderPattern = '^\s*{0}\s*\{{' -f [System.Text.RegularExpressions.Regex]::Escape($Block.BlockKind.ToString()) + + If ($Block.Extent.Text -cnotmatch $HeaderPattern) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalNamedBlock' ` + -Extent $Block.Extent ` + -Message ( + "Function '{0}' named block must start with '{1}'." -f + $FunctionAst.Name, + $ExpectedHeader + ) + } + } + + For ($Index = 1; $Index -lt $Blocks.Count; $Index++) { + [System.Management.Automation.Language.NamedBlockAst]$Private:PreviousBlock = $Blocks[$Index - 1] + [System.Management.Automation.Language.NamedBlockAst]$Private:CurrentBlock = $Blocks[$Index] + + If ($PreviousBlock.Extent.EndLineNumber -eq $CurrentBlock.Extent.StartLineNumber) { + Continue + } + + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-CanonicalNamedBlock' ` + -Extent $CurrentBlock.Extent ` + -Message ( + "Function '{0}' named block transition must be cuddled as '}} {1} {{'." -f + $FunctionAst.Name, + $CurrentBlock.BlockKind.ToString() + ) + } + } + +} + +Function Measure-NoRemoveVariableCleanup { <# .SYNOPSIS Flags Remove-Variable cleanup in function End blocks. @@ -1519,20 +1935,20 @@ function Measure-NoRemoveVariableCleanup { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { - if ($Null -eq $FunctionAst.Body.EndBlock) { - continue + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + If ($Null -eq $FunctionAst.Body.EndBlock) { + Continue } $FunctionAst.Body.EndBlock.FindAll( { - param ( + Param ( [System.Management.Automation.Language.Ast] $Ast ) @@ -1557,7 +1973,7 @@ function Measure-NoRemoveVariableCleanup { } -function Measure-NoNewVariableDeclaration { +Function Measure-NoNewVariableDeclaration { <# .SYNOPSIS Flags New-Variable local declarations inside functions. @@ -1571,16 +1987,16 @@ function Measure-NoNewVariableDeclaration { SupportsShouldProcess = $False )] [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])] - param ( + Param ( [Parameter(Mandatory = $True)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) - foreach ($FunctionAst in Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { $FunctionAst.Body.FindAll( { - param ( + Param ( [System.Management.Automation.Language.Ast] $Ast ) @@ -1606,4 +2022,98 @@ function Measure-NoNewVariableDeclaration { } +Function Measure-SoftReturn { + <# + .SYNOPSIS + Flags hard returns and missing SG-6 soft-return debug anchors. + #> + [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(Mandatory = $True)] + [System.Management.Automation.Language.ScriptBlockAst] + $ScriptBlockAst + ) + + ForEach ($FunctionAst In Get-HouseRuleFunctionAst -ScriptBlockAst $ScriptBlockAst) { + $FunctionAst.Body.FindAll( + { + Param ( + [System.Management.Automation.Language.Ast] + $Ast + ) + + $Ast -is [System.Management.Automation.Language.ReturnStatementAst] + }, + $True + ) | Where-Object -FilterScript { + Test-HouseRuleAstBelongsToFunction -Ast $PSItem -FunctionAst $FunctionAst + } | ForEach-Object -Process { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-SoftReturn' ` + -Extent $PSItem.Extent ` + -Message ( + "Function '{0}' uses 'return'; SG-6 requires single-exit (soft return)." -f + $FunctionAst.Name + ) + } + + If ((Test-HouseRuleFunctionDeclaresPrivateResult -FunctionAst $FunctionAst) -eq $False) { + Continue + } + + [System.Boolean]$Private:HasExplicitEndBlock = [System.Boolean]( + $Null -ne $FunctionAst.Body.EndBlock -and + $FunctionAst.Body.EndBlock.Unnamed -eq $False + ) + [System.Boolean]$Private:HasPipelineShape = [System.Boolean]( + $Null -ne $FunctionAst.Body.ProcessBlock -or + $HasExplicitEndBlock -eq $True + ) + [System.String]$Private:Message = ( + "Function '{0}' last statement must be Write-Debug '... Exiting ...' (SG-6 soft return)." -f + $FunctionAst.Name + ) + + If ($HasPipelineShape -eq $True) { + If ( + $Null -ne $FunctionAst.Body.ProcessBlock -and + (Test-HouseRuleLastStatementExitingDebug -BlockAst $FunctionAst.Body.ProcessBlock) -eq $False + ) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-SoftReturn' ` + -Extent $FunctionAst.Body.ProcessBlock.Extent ` + -Message $Message + } + + If ($HasExplicitEndBlock -eq $True) { + If ((Test-HouseRuleLastStatementExitingDebug -BlockAst $FunctionAst.Body.EndBlock) -eq $False) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-SoftReturn' ` + -Extent $FunctionAst.Body.EndBlock.Extent ` + -Message $Message + } + } Else { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-SoftReturn' ` + -Extent $FunctionAst.Extent ` + -Message $Message + } + } ElseIf ((Test-HouseRuleLastStatementExitingDebug -BlockAst $FunctionAst.Body.EndBlock) -eq $False) { + ConvertTo-HouseRuleDiagnosticRecord ` + -RuleName 'Measure-SoftReturn' ` + -Extent $FunctionAst.Body.EndBlock.Extent ` + -Message $Message + } + } + +} + Export-ModuleMember -Function 'Measure-*' diff --git a/docs/STYLE-GUIDE.md b/docs/STYLE-GUIDE.md index ffc29fb..94d5939 100644 --- a/docs/STYLE-GUIDE.md +++ b/docs/STYLE-GUIDE.md @@ -34,7 +34,7 @@ scope that works** — and when a wider scope is genuinely needed, that choice i A **bare, unscoped creation** (`$X = …`) is the only prohibited form: it is indistinguishable from a forgotten `Private` and hides the scope decision. 3. **Placement** follows the shape SG-2 assigns: pipeline-capable functions - **declare** locals in `begin` and **reset** them at the top of `process` (direct + **declare** locals in `Begin` and **reset** them at the top of `Process` (direct assignment to the typed default, so state resets between piped items); flat functions declare locals inline. 4. **No `New-Variable`** for locals (slower, honors `-WhatIf` inside @@ -44,8 +44,8 @@ scope that works** — and when a wider scope is genuinely needed, that choice i reference sample). **Exempt:** parameters, PowerShell automatic variables (`$PSItem`, `$_`, -`$PSCmdlet`, `$true`/`$false`/`$null`, `$args`, `$matches`, …), `for`/`foreach` -loop induction variables, and `process`-top **resets** of a `begin`-declared +`$PSCmdlet`, `$true`/`$false`/`$null`, `$args`, `$matches`, …), `For`/`ForEach` +loop induction variables, and `Process`-top **resets** of a `Begin`-declared `$Private:` variable (the reset re-assigns; it does not create). **Why.** Scope creep is the enemy: a variable readable where it has no business @@ -64,14 +64,14 @@ worse by this rule. - a rule that flags any **bare, unscoped** function-local creation (`$X = …`) — explicit `$Private:` / `$Local:` / `$Script:` declarations all pass; - a rule that, for pipeline-capable functions, flags locals not declared in - `begin` or not reset at the top of `process`; + `Begin` or not reset at the top of `Process`; - a rule that flags `Remove-Variable` used as end-of-scope cleanup; - a rule that flags `New-Variable` used for local declarations inside functions. ### Example (pipeline-capable) ```powershell -function ConvertTo-Thing { +Function ConvertTo-Thing { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -81,34 +81,42 @@ function ConvertTo-Thing { SupportsShouldProcess = $False )] [OutputType([System.String])] - param ( + Param ( [Parameter(Mandatory = $True, ValueFromPipeline = $True)] [System.String] $InputValue ) - begin { - Write-Debug -Message '[ConvertTo-Thing] Entering Begin' + Begin { + Write-Debug -Message:'[ConvertTo-Thing] Entering Begin' # Initialize Variable(s) + [System.String]$Private:ConvertedValue = [System.String]::Empty [System.String]$Private:Result = [System.String]::Empty - Write-Debug -Message '[ConvertTo-Thing] Exiting Begin' - } - process { + Write-Debug -Message:'[ConvertTo-Thing] Exiting Begin' + } Process { + Write-Debug -Message:'[ConvertTo-Thing] Entering Process' + + # Reset Variable(s) + $ConvertedValue = [System.String]::Empty $Result = [System.String]::Empty - $Result = $InputValue.ToUpperInvariant() + + $ConvertedValue = $InputValue.ToUpperInvariant() + [System.String]$Result = $ConvertedValue $Result + + Write-Debug -Message:'[ConvertTo-Thing] Exiting Process' } } ``` -(No `end` block: named blocks are written only when they carry real content — -an empty `end { }` is exactly the ceremony SG-2 prohibits.) +(No `End` block: named blocks are written only when they carry real content — +an empty `End { }` is exactly the ceremony SG-2 prohibits.) ### Example (flat function — no pipeline parameter, no named blocks) ```powershell -function Get-Thing { +Function Get-Thing { [CmdletBinding( ConfirmImpact = 'None', DefaultParameterSetName = 'default', @@ -118,10 +126,20 @@ function Get-Thing { SupportsShouldProcess = $False )] [OutputType([System.Object[]])] - param () + Param () + Write-Debug -Message:'[Get-Thing] Entering' + + # Initialize Variable(s) [System.Collections.Generic.List[object]]$Private:Items = [System.Collections.Generic.List[object]]::new() - foreach ($Name in 'a', 'b') { $Private:Items.Add($Name) } - , $Private:Items.ToArray() + [System.Object[]]$Private:Result = @() + + [void]$Private:Items.Add('a') + [void]$Private:Items.Add('b') + + [System.Object[]]$Result = $Items.ToArray() + $Result + + Write-Debug -Message:'[Get-Thing] Exiting' } ``` @@ -171,35 +189,37 @@ stays silent here by design, and review carries it. ### 2c. Shape follows pipelining **[mechanical — analyzer-enforced]** -- A function that **declares a pipeline parameter MUST handle it in `process`** - (built-in `PSUseProcessBlockForPipelineCommand`; without a `process` block the +- A function that **declares a pipeline parameter MUST handle it in `Process`** + (built-in `PSUseProcessBlockForPipelineCommand`; without a `Process` block the function executes once and binds only the **last** piped item) and MUST emit - per-item output from `process` — genuine aggregators that collect in `process` - and emit a single result from `end` are the documented exception. + per-item output from `Process` — genuine aggregators that collect in `Process` + and emit a single result from `End` are the documented exception. - A function that **declares NO pipeline parameter MUST NOT use - `begin`/`process`/`end` at all.** Code runs in the implicit `end` block; locals + `Begin`/`Process`/`End` at all.** Code runs in the implicit `End` block; locals are declared inline per SG-1. Named blocks on a non-pipeline function are pure - ceremony: a direct call runs `process` exactly once, so the blocks change + ceremony: a direct call runs `Process` exactly once, so the blocks change nothing and only obscure whether the function actually streams. (No built-in rule covers this inverse — PSScriptAnalyzer issue #1571 — hence the custom house rule.) - Empty named blocks are prohibited in all cases (aligned with the PoshCode guidance "do not include empty named blocks"); under SG-1 every block a pipeline - function keeps carries real content (`begin` = typed initialization, `process` + function keeps carries real content (`Begin` = typed initialization, `Process` = work), so this falls out naturally. **Why.** The construct question removes cmdlet ceremony from code that was never a cmdlet; the pipeline default keeps real operations composable (the reason the shell exists); and shape-follows-pipelining makes a function's streaming contract visible -at a glance — `begin/process/end` present *means* "this streams", absent *means* +at a glance — `Begin`/`Process`/`End` present *means* "this streams", absent *means* "this is a single-shot call". Ambiguity is the enemy. **Enforced by:** - built-in `PSUseProcessBlockForPipelineCommand` (Warning) — pipeline parameter - handled outside `process`; + handled outside `Process`; - custom rule `Measure-FlatNonPipelineFunction` (Warning) — any - `begin`/`process`/`end`/`dynamicparam` block on a function whose `param` block + `Begin`/`Process`/`End`/`DynamicParam` block on a function whose `Param` block declares no `ValueFromPipeline`/`ValueFromPipelineByPropertyName` parameter; +- custom rule `Measure-CanonicalNamedBlock` (Warning) — named blocks must use + canonical `Begin`/`Process`/`End` spelling and cuddled block transitions; - 2a and 2b are **[judgment]** — review-enforced via the advisor skill; the analyzer is intentionally silent. @@ -215,33 +235,33 @@ at a glance — `begin/process/end` present *means* "this streams", absent *mean **Rule.** All braceable statements use **One True Brace Style**: -- the **opening brace** sits at the **end of the construct's line** (`function X {`, - `if (…) {`, `foreach (…) {`, `try {`, `process {`); +- the **opening brace** sits at the **end of the construct's line** (`Function X {`, + `If (…) {`, `ForEach (…) {`, `Try {`, `Process {`); - the **closing brace** sits at the **start of its own line**; -- `else` / `elseif` / `catch` / `finally` are **cuddled** to the preceding closing - brace — `} else {`, `} elseif (…) {`, `} catch {`, `} finally {` — never on a line +- `Else` / `ElseIf` / `Catch` / `Finally` are **cuddled** to the preceding closing + brace — `} Else {`, `} ElseIf (…) {`, `} Catch {`, `} Finally {` — never on a line of their own. -One-line blocks are **allowed** (e.g. `$x = if ($c) { 'a' } else { 'b' }`, -`foreach ($i in $set) { $list.Add($i) }`); they are exempt from brace placement. +One-line blocks are **allowed** (e.g. `$x = If ($c) { 'a' } Else { 'b' }`, +`ForEach ($i In $set) { $list.Add($i) }`); they are exempt from brace placement. Braces themselves are not optional — PowerShell's grammar already requires them for -`if`/`while`/`foreach`/etc. (`if ($x) Do-Thing` is a syntax error), so there is no +`If`/`While`/`ForEach`/etc. (`If ($x) Do-Thing` is a syntax error), so there is no "always-braces" choice to make. **Why.** OTBS is the PowerShell community-idiomatic default (PoshCode PowerShellPracticeAndStyle recommends it by name): script-block callers (`ForEach-Object { … }`, `Where-Object { … }`, DSC) force same-line opening braces, so OTBS is the only brace style consistent across every construct the language has. -Cuddled branch keywords keep an `if/else` or `try/catch` reading as one unit instead of +Cuddled branch keywords keep an `If`/`Else` or `Try`/`Catch` reading as one unit instead of visually splitting it. (This is an **alignment** with community guidance, not a house divergence.) **The one easy-to-get-wrong setting.** Cuddling is enforced by `PSPlaceCloseBrace` **only when `NewLineAfter = $false`**. At the rule's *default* `$true`, the analyzer forces a newline after every closing brace and actively **breaks** cuddling (producing -the Stroustrup form). There is no dedicated "cuddled-else" property; `NewLineAfter = +the Stroustrup form). There is no dedicated "cuddled branch" property; `NewLineAfter = $false` is the lever (its `GetViolationsForUncuddledBranches` path flags uncuddled -`else`/`elseif`/`catch`/`finally`). Ref: PSScriptAnalyzer issue #754 (Closed/Fixed). +`Else`/`ElseIf`/`Catch`/`Finally`). Ref: PSScriptAnalyzer issue #754 (Closed/Fixed). **Enforced by** the built-in formatting rules (Warning), configured in `PSScriptAnalyzerSettings.psd1` — no custom rule needed: @@ -265,9 +285,9 @@ PSAlignAssignmentStatement = @{ Enable = $true; CheckEnums = $false; CheckHashta ``` `Invoke-Formatter` can drive a bulk reformat from the same settings, but it has known -edge bugs around branch placement (half-cuddled `else`, no inter-statement newline +edge bugs around branch placement (half-cuddled `Else`, no inter-statement newline insertion — issues #508, #794), so any auto-reformat must be re-linted and the -`else`/`catch`/`finally` sites eyeballed. Operator, pipe, and parameter spacing are +`Else`/`Catch`/`Finally` sites eyeballed. Operator, pipe, and parameter spacing are also formatter-enforced; the hashtable assignment carve-out is deliberate so aligned hashtables continue to satisfy `PSAlignAssignmentStatement`. Enum member alignment is disabled because `CheckOperator` owns assignment-operator spacing outside hashtables. @@ -366,3 +386,126 @@ AST: `AttributeAst.NamedArguments` must be alphabetical (a, b); within each `TypeConstraintAst`, and `[Parameter]`→`[Alias]`→`[Validate* alphabetical]`→type order holds (c); `ParamBlockAst.Parameters` names must be alphabetical **unless** the function trips the Guard above (d). No built-in rule covers any of this. + +--- + +## SG-6 — Soft return and canonical token surface **[mechanical where decidable — analyzer-enforced]** + +See [ADR-repo/0007](decision-records/repo/0007-sg6-soft-return-and-canonical-call-syntax.md) +for the accepted rationale and trade-offs. + +**Intent.** Every output-producing function keeps its domain result traceable until +the final handoff, emits a single explicitly typed `$Private:Result`, and flows +through the trailing `Write-Debug '... Exiting ...'` breakpoint anchor. The same rule +set also fixes two house-level readability choices: colon-form command parameters +and canonical PascalCase PowerShell keywords. + +**Rule.** + +1. **Domain-named computation, typed result.** Output-producing functions compute + into meaningful domain variables (`$HashString`, `$StoreCertificates`, + `$SelectedCertificates`) until the final handoff. Declare + `[]$Private:Result = ` in the SG-1 initialization + location; pipeline functions reset `$Result` at the top of `Process` with their + other Begin-declared locals. Immediately before emitting, assign the final domain + value into an explicitly typed result: `[]$Result = $DomainValue`. +2. **Soft return, single-exit.** Emit bare `$Result` on its own line immediately + after the final typed handoff. Do not use `Return` anywhere in a function, + including guard/early-exit returns; restructure guards so control reaches the + trailing debug anchor. Loop-local `Continue`/`Break` are fine. Conditional-output + functions emit `$Result` only in producing branches, while the trailing debug + anchor still executes on every non-terminating path. +3. **`Write-Debug` anchor.** Flat functions start with + `Write-Debug -Message:'[] Entering'` and end with + `Write-Debug -Message:'[] Exiting'`; `$Result` is emitted immediately + before the Exiting line. Pipeline functions put + `Write-Debug -Message:'[] Entering '` first in each named block, + reset `Process` variables immediately after the `Entering Process` anchor, and + end each named block with the matching `Exiting ` anchor. +4. **Colon-form parameters.** Command calls bind named parameters as `-Name:Value`. + Parenthesize expression values (`-ErrorId:([ExporterExitCode]::Unhandled)`) so the + colon binder cannot greedily consume more than intended. Self-delimiting values + need no parentheses: simple variables (`-Certificate:$PSItem`), literals, and + scriptblocks (`-Process:{ ... }`, not `-Process:({ ... })`). There is no space + after the colon. Switches may be explicit (`-IsFatal:$True`). +5. **PascalCase keywords.** PowerShell keywords use the canonical house spelling: + `Function`, `Param`, `Begin`, `Process`, `End`, `If`, `ElseIf`, `Else`, `ForEach`, + `For`, `While`, `Do`, `Switch`, `Try`, `Catch`, `Finally`, `Throw`, `Trap`, + `Exit`, `Enum`, `DynamicParam`, and the other tokenized language keywords. + Operators and variable names are unaffected. + +**Exempt.** Functions that never emit pipeline output, such as terminating-error +helpers that only throw, do not soft-return. Type definitions such as enums are also +outside the soft-return rule. Terminating-error paths are not required to reach the +success-path Exiting anchor. + +**Why.** The Exiting line is a reliable end-of-function breakpoint with all locals, +including the typed result, still in scope. Domain-named locals make the calculation +auditable; the final typed `$Result` makes the emitted shape explicit; single-exit +control flow keeps the debug anchor meaningful. + +**Trade-off.** PascalCase keywords and colon-form parameters are non-idiomatic house +preferences. Community examples and `PSUseCorrectCasing` prefer lowercase keywords, +and colon-form has a real greedy-binding hazard. The house accepts those costs for +TargetState fidelity and auditability, with the parenthesize-expression guard as the +safe subset for colon-form calls. + +**Enforced by:** + +- custom rule `Measure-SoftReturn` (Warning) — flags `Return` inside functions and + missing trailing `Write-Debug '... Exiting ...'` anchors where mechanically + decidable; +- custom rule `Measure-CanonicalNamedBlock` (Warning) — named-block spelling and + cuddled `} Process {` / `} End {` transitions; +- custom rule `Measure-CanonicalKeywordCasing` (Warning) — PascalCase keyword + spelling; +- built-in `PSUseCorrectCasing` is disabled because it is incompatible with the + house PascalCase keyword convention; house rules own keyword casing instead; +- colon-form parameter use is review-backed because the safe subset is not + currently machine-enforced. + +### Example (flat) + +```powershell +Function Get-Thing { + [CmdletBinding( + ConfirmImpact = 'None', + DefaultParameterSetName = 'default', + HelpUri = 'https://github.com///blob/main/docs/reference/functions.md#get-thing', + PositionalBinding = $False, + SupportsPaging = $False, + SupportsShouldProcess = $False + )] + [OutputType([System.String])] + Param () + Write-Debug -Message:'[Get-Thing] Entering' + + # Initialize Variable(s) + [System.String]$Private:Name = [System.String]::Empty + [System.String]$Private:Result = [System.String]::Empty + + $Name = 'thing' + [System.String]$Result = $Name + $Result + + Write-Debug -Message:'[Get-Thing] Exiting' +} +``` + +### Example (pipeline) + +```powershell +Process { + Write-Debug -Message:'[ConvertTo-Thing] Entering Process' + + # Reset Variable(s) + $ConvertedValue = [System.String]::Empty + $Result = [System.String]::Empty + + $ConvertedValue = $InputValue.ToUpperInvariant() + [System.String]$Result = $ConvertedValue + $Result + + Write-Debug -Message:'[ConvertTo-Thing] Exiting Process' +} +``` diff --git a/docs/how-to/add-a-function.md b/docs/how-to/add-a-function.md index 500210d..92ef7a6 100644 --- a/docs/how-to/add-a-function.md +++ b/docs/how-to/add-a-function.md @@ -32,7 +32,7 @@ Goal: add a new exported function to the module and keep CI green. 4. **Validate locally.** ```bash - pwsh -c "Invoke-ScriptAnalyzer -Path . -Settings PSGallery -Recurse" + pwsh -c "Invoke-ScriptAnalyzer -Path . -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse" pwsh -File tests/Invoke-Tests.ps1 ``` diff --git a/docs/reference/clean-function-idiom.ps1 b/docs/reference/clean-function-idiom.ps1 index 4084a52..bca4a11 100644 --- a/docs/reference/clean-function-idiom.ps1 +++ b/docs/reference/clean-function-idiom.ps1 @@ -1,6 +1,6 @@ #Requires -Version 5.1 -function Get-TemplateGreeting { +Function Get-TemplateGreeting { <# .SYNOPSIS Returns an analyzer-clean greeting. @@ -28,7 +28,7 @@ function Get-TemplateGreeting { SupportsShouldProcess = $False )] [OutputType([System.String])] - param ( + Param ( [Parameter( Mandatory = $True, ValueFromPipeline = $True @@ -38,21 +38,28 @@ function Get-TemplateGreeting { $Name ) - begin { - Write-Debug -Message '[Get-TemplateGreeting] Entering Begin' - Write-Debug -Message '[Get-TemplateGreeting] Exiting Begin' - } + Begin { + Write-Debug -Message:'[Get-TemplateGreeting] Entering Begin' - process { - Write-Debug -Message '[Get-TemplateGreeting] Entering Process' + # Initialize Variable(s) + [System.String]$Private:TrimmedName = [System.String]::Empty + [System.String]$Private:Result = [System.String]::Empty - [System.String]('Hello, {0}!' -f $Name.Trim()) + Write-Debug -Message:'[Get-TemplateGreeting] Exiting Begin' + } Process { + Write-Debug -Message:'[Get-TemplateGreeting] Entering Process' - Write-Debug -Message '[Get-TemplateGreeting] Exiting Process' - } + # Reset Variable(s) + $TrimmedName = [System.String]::Empty + $Result = [System.String]::Empty + + $TrimmedName = $Name.Trim() + [System.String]$Result = 'Hello, {0}!' -f $TrimmedName + $Result - end { - Write-Debug -Message '[Get-TemplateGreeting] Entering End' - Write-Debug -Message '[Get-TemplateGreeting] Exiting End' + Write-Debug -Message:'[Get-TemplateGreeting] Exiting Process' + } End { + Write-Debug -Message:'[Get-TemplateGreeting] Entering End' + Write-Debug -Message:'[Get-TemplateGreeting] Exiting End' } } diff --git a/docs/reference/module-structure.md b/docs/reference/module-structure.md index 9869c89..c7dbeb4 100644 --- a/docs/reference/module-structure.md +++ b/docs/reference/module-structure.md @@ -42,5 +42,5 @@ This template lays out a PowerShell module as follows. | Check | Tool | Gate | | ---------------- | ---------------- | --------------------------------------- | | Workflow lint | actionlint | Errors fail the job | -| Static analysis | PSScriptAnalyzer | PSGallery ruleset; errors/warnings fail | +| Static analysis | PSScriptAnalyzer | House settings; errors/warnings fail | | Tests + coverage | Pester v5 | All tests pass; coverage ≥ 80% | diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index d8a3f27..521e876 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -36,7 +36,7 @@ Then update the references: ## 3. Run the checks locally ```bash -pwsh -c "Invoke-ScriptAnalyzer -Path . -Settings PSGallery -Recurse" +pwsh -c "Invoke-ScriptAnalyzer -Path . -Settings ./PSScriptAnalyzerSettings.psd1 -Recurse" pwsh -File tests/Invoke-Tests.ps1 ``` diff --git a/src/SampleModule/Private/Format-GreetingName.ps1 b/src/SampleModule/Private/Format-GreetingName.ps1 index d723a37..8cf352a 100644 --- a/src/SampleModule/Private/Format-GreetingName.ps1 +++ b/src/SampleModule/Private/Format-GreetingName.ps1 @@ -1,4 +1,4 @@ -function Format-GreetingName { +Function Format-GreetingName { <# .SYNOPSIS Normalizes a name for use in a greeting. @@ -23,14 +23,36 @@ function Format-GreetingName { SupportsPaging = $False, SupportsShouldProcess = $False )] - [OutputType([string])] - param( + [OutputType([System.String])] + Param ( [Parameter(Mandatory = $True, ValueFromPipeline = $True)] [ValidateNotNull()] - [string]$Name + [System.String] + $Name ) - process { - ($Name -replace '\s+', ' ').Trim() + Begin { + Write-Debug -Message:'[Format-GreetingName] Entering Begin' + + # Initialize Variable(s) + [System.String]$Private:NormalizedName = [System.String]::Empty + [System.String]$Private:Result = [System.String]::Empty + + Write-Debug -Message:'[Format-GreetingName] Exiting Begin' + } Process { + Write-Debug -Message:'[Format-GreetingName] Entering Process' + + # Reset Variable(s) + $NormalizedName = [System.String]::Empty + $Result = [System.String]::Empty + + $NormalizedName = ($Name -replace '\s+', ' ').Trim() + [System.String]$Result = $NormalizedName + $Result + + Write-Debug -Message:'[Format-GreetingName] Exiting Process' + } End { + Write-Debug -Message:'[Format-GreetingName] Entering End' + Write-Debug -Message:'[Format-GreetingName] Exiting End' } } diff --git a/src/SampleModule/Public/Get-Greeting.ps1 b/src/SampleModule/Public/Get-Greeting.ps1 index f439046..fa49883 100644 --- a/src/SampleModule/Public/Get-Greeting.ps1 +++ b/src/SampleModule/Public/Get-Greeting.ps1 @@ -1,4 +1,4 @@ -function Get-Greeting { +Function Get-Greeting { <# .SYNOPSIS Builds a greeting string for the supplied name. @@ -36,29 +36,41 @@ function Get-Greeting { SupportsPaging = $False, SupportsShouldProcess = $False )] - [OutputType([string])] - param( + [OutputType([System.String])] + Param ( [Parameter()] [ValidateNotNullOrEmpty()] - [string]$Greeting = 'Hello', + [System.String] + $Greeting = 'Hello', - [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Parameter(Mandatory = $True, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] [ValidateNotNullOrEmpty()] - [string]$Name + [System.String] + $Name ) - begin { - Write-Debug -Message '[Get-Greeting] Entering Begin' + Begin { + Write-Debug -Message:'[Get-Greeting] Entering Begin' # Initialize Variable(s) [System.String]$Private:Normalized = [System.String]::Empty + [System.String]$Private:Result = [System.String]::Empty - Write-Debug -Message '[Get-Greeting] Exiting Begin' - } + Write-Debug -Message:'[Get-Greeting] Exiting Begin' + } Process { + Write-Debug -Message:'[Get-Greeting] Entering Process' - process { + # Reset Variable(s) $Normalized = [System.String]::Empty - $Normalized = Format-GreetingName -Name $Name - '{0}, {1}!' -f $Greeting, $Normalized + $Result = [System.String]::Empty + + $Normalized = Format-GreetingName -Name:$Name + [System.String]$Result = '{0}, {1}!' -f $Greeting, $Normalized + $Result + + Write-Debug -Message:'[Get-Greeting] Exiting Process' + } End { + Write-Debug -Message:'[Get-Greeting] Entering End' + Write-Debug -Message:'[Get-Greeting] Exiting End' } } diff --git a/src/SampleModule/SampleModule.psm1 b/src/SampleModule/SampleModule.psm1 index f911eb4..d58085c 100644 --- a/src/SampleModule/SampleModule.psm1 +++ b/src/SampleModule/SampleModule.psm1 @@ -19,20 +19,20 @@ $publicRoot = Join-Path -Path $PSScriptRoot -ChildPath 'Public' $privateRoot = Join-Path -Path $PSScriptRoot -ChildPath 'Private' $publicFunctions = @() -if (Test-Path -LiteralPath $publicRoot) { +If (Test-Path -LiteralPath $publicRoot) { $publicFunctions = @(Get-ChildItem -LiteralPath $publicRoot -Filter '*.ps1' -File -ErrorAction SilentlyContinue) } $privateFunctions = @() -if (Test-Path -LiteralPath $privateRoot) { +If (Test-Path -LiteralPath $privateRoot) { $privateFunctions = @(Get-ChildItem -LiteralPath $privateRoot -Filter '*.ps1' -File -ErrorAction SilentlyContinue) } -foreach ($file in @($privateFunctions) + @($publicFunctions)) { - try { +ForEach ($file In @($privateFunctions) + @($publicFunctions)) { + Try { . $file.FullName - } catch { - throw "Failed to import function file '$($file.FullName)': $($_.Exception.Message)" + } Catch { + Throw "Failed to import function file '$($file.FullName)': $($_.Exception.Message)" } } diff --git a/tests/HouseRules.Tests.ps1 b/tests/HouseRules.Tests.ps1 index 08393fe..dc8f5b1 100644 --- a/tests/HouseRules.Tests.ps1 +++ b/tests/HouseRules.Tests.ps1 @@ -4,7 +4,7 @@ Describe 'SG-1 house analyzer rules' { BeforeAll { $AnalyzerRulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\analyzers\HouseRules.psm1' $script:AnalyzerRulePath = $AnalyzerRulePath - if (-not (Get-Module -Name PSScriptAnalyzer)) { + If (-not (Get-Module -Name PSScriptAnalyzer)) { Import-Module -Name PSScriptAnalyzer -ErrorAction Stop } } @@ -269,12 +269,12 @@ function Get-Thing { Describe 'SG-4 house analyzer rules' { BeforeAll { $script:AnalyzerRulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\analyzers\HouseRules.psm1' - if (-not (Get-Module -Name PSScriptAnalyzer)) { + If (-not (Get-Module -Name PSScriptAnalyzer)) { Import-Module -Name PSScriptAnalyzer -ErrorAction Stop } $script:NewExplicitBindingFixture = { - param ( + Param ( [Parameter()] [AllowNull()] [System.String] @@ -297,9 +297,9 @@ Describe 'SG-4 house analyzer rules' { } $OptionLines = [System.Collections.Generic.List[System.String]]::new() - for ($Index = 0; $Index -lt $Options.Count; $Index++) { + For ($Index = 0; $Index -lt $Options.Count; $Index++) { $Line = ' {0} = {1}' -f $Options[$Index].Name, $Options[$Index].Value - if ($Index -lt ($Options.Count - 1)) { + If ($Index -lt ($Options.Count - 1)) { $Line = '{0},' -f $Line } @@ -307,7 +307,7 @@ Describe 'SG-4 house analyzer rules' { } $OutputTypeLine = [System.String]::Empty - if ($IncludeOutputType -eq $True) { + If ($IncludeOutputType -eq $True) { $OutputTypeLine = ' [OutputType([System.String])]' } @@ -345,7 +345,7 @@ $OutputTypeLine 'SupportsPaging' ) - foreach ($RequiredOption in $RequiredOptions) { + ForEach ($RequiredOption In $RequiredOptions) { $Results = Invoke-ScriptAnalyzer ` -ScriptDefinition (& $script:NewExplicitBindingFixture -MissingOption $RequiredOption) ` -CustomRulePath $script:AnalyzerRulePath ` @@ -394,7 +394,7 @@ function Get-Thing { Describe 'SG-5 house analyzer rules' { BeforeAll { $script:AnalyzerRulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\analyzers\HouseRules.psm1' - if (-not (Get-Module -Name PSScriptAnalyzer)) { + If (-not (Get-Module -Name PSScriptAnalyzer)) { Import-Module -Name PSScriptAnalyzer -ErrorAction Stop } } @@ -564,7 +564,7 @@ function Get-Thing { $Results.Message | Should -Match 'SG-5d' } - It 'does not flag unsorted parameter names when explicit Position trips the guard' { + It 'flags Parameter Position even when parameter order is load-bearing' { $ScriptDefinition = @' function Get-Thing { [CmdletBinding( @@ -597,7 +597,10 @@ function Get-Thing { $Results = @($Results | Where-Object -FilterScript { $PSItem.RuleName -eq 'Measure-CanonicalAttributeOrder' }) - $Results | Should -HaveCount 0 + $Results.RuleName | Should -Contain 'Measure-CanonicalAttributeOrder' + [System.String]$MessageText = $Results.Message -join "`n" + $MessageText | Should -Match 'Position' + $MessageText | Should -Match 'SG-5e' } It 'accepts the canonical declaration idiom' { diff --git a/tests/Invoke-Tests.ps1 b/tests/Invoke-Tests.ps1 index 092b094..6e13d1f 100644 --- a/tests/Invoke-Tests.ps1 +++ b/tests/Invoke-Tests.ps1 @@ -20,7 +20,7 @@ Minimum line-coverage percentage required for the run to succeed. #> [CmdletBinding()] -param( +Param ( [string]$OutputPath = '', [ValidateRange(0, 100)] [double]$MinimumCoverage = 80 @@ -28,12 +28,12 @@ param( $ErrorActionPreference = 'Stop' -if ([string]::IsNullOrWhiteSpace($OutputPath)) { +If ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path -Path $PSScriptRoot -ChildPath '../TestResults' } $repoRoot = (Resolve-Path -LiteralPath (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path -if (-not (Test-Path -LiteralPath $OutputPath)) { +If (-not (Test-Path -LiteralPath $OutputPath)) { $null = New-Item -ItemType Directory -Path $OutputPath -Force } $OutputPath = (Resolve-Path -LiteralPath $OutputPath).Path