Rules/PSAvoidOutputTypeDrift.psm1
|
Set-StrictMode -Version Latest $script:RuleName = 'PSAvoidOutputTypeDrift' $script:RuleMessage = 'Avoid changing emitted output type across switch-driven branches in pipeline-facing commands. Keep output shape stable so the command remains predictable in pipelines.' function Measure-PSAvoidOutputTypeDrift { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.ScriptBlockAst] $ScriptBlockAst ) $functions = $ScriptBlockAst.FindAll({ param($Ast) $Ast -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) foreach ($functionAst in $functions) { if (-not (Test-IsPipelineFacingCommand -FunctionAst $functionAst)) { continue } if (-not (Test-HasOutputTypeDrift -FunctionAst $functionAst)) { continue } [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord]::new( $script:RuleMessage, $functionAst.Extent, $script:RuleName, [Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticSeverity]::Warning, $null, $null, $null ) } } function Test-IsPipelineFacingCommand { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) if (-not $FunctionAst.Body.ParamBlock) { return $false } $hasCmdletBinding = $FunctionAst.Body.ParamBlock.Attributes | Where-Object { $_.TypeName.GetReflectionType().FullName -eq 'System.Management.Automation.CmdletBindingAttribute' } if (-not $hasCmdletBinding) { return $false } return $FunctionAst.Body.ProcessBlock -ne $null } function Test-HasOutputTypeDrift { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) $switchParameterNames = @(Get-SwitchParameterNames -FunctionAst $FunctionAst) if ($switchParameterNames.Count -eq 0) { return $false } $ifStatements = Get-OutputBearingIfStatements -FunctionAst $FunctionAst foreach ($ifStatement in $ifStatements) { if (-not (Test-IfStatementUsesSwitchCondition -IfStatementAst $ifStatement -SwitchParameterNames $switchParameterNames)) { continue } $branchKinds = @(Get-IfStatementOutputKinds -IfStatementAst $ifStatement) if ($branchKinds.Count -gt 1) { return $true } } return $false } function Get-SwitchParameterNames { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @( $FunctionAst.Body.ParamBlock.Parameters | Where-Object { $_.StaticType -eq [System.Management.Automation.SwitchParameter] } | ForEach-Object { $_.Name.VariablePath.UserPath } ) } function Get-OutputBearingIfStatements { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) @( $FunctionAst.FindAll({ param($Ast) $Ast -is [System.Management.Automation.Language.IfStatementAst] }, $true) ) } function Test-IfStatementUsesSwitchCondition { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.IfStatementAst] $IfStatementAst, [Parameter(Mandatory)] [string[]] $SwitchParameterNames ) foreach ($clause in $IfStatementAst.Clauses) { $variables = $clause.Item1.FindAll({ param($Ast) $Ast -is [System.Management.Automation.Language.VariableExpressionAst] }, $true) foreach ($variable in $variables) { if ($SwitchParameterNames -contains $variable.VariablePath.UserPath) { return $true } } } return $false } function Get-IfStatementOutputKinds { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.IfStatementAst] $IfStatementAst ) $kinds = New-Object System.Collections.Generic.List[string] foreach ($clause in $IfStatementAst.Clauses) { foreach ($kind in @(Get-BranchOutputKinds -StatementBlockAst $clause.Item2)) { if (-not $kinds.Contains($kind)) { $kinds.Add($kind) } } } if ($IfStatementAst.ElseClause) { foreach ($kind in @(Get-BranchOutputKinds -StatementBlockAst $IfStatementAst.ElseClause)) { if (-not $kinds.Contains($kind)) { $kinds.Add($kind) } } } else { foreach ($kind in @(Get-ContinuationOutputKinds -IfStatementAst $IfStatementAst)) { if (-not $kinds.Contains($kind)) { $kinds.Add($kind) } } } $kinds } function Get-BranchOutputKinds { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.Ast] $StatementBlockAst ) $kinds = New-Object System.Collections.Generic.List[string] foreach ($statement in $StatementBlockAst.Statements) { $kind = Get-OutputKind -StatementAst $statement if ($null -eq $kind) { continue } if (-not $kinds.Contains($kind)) { $kinds.Add($kind) } } $kinds } function Get-ContinuationOutputKinds { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.IfStatementAst] $IfStatementAst ) $parentBlock = $IfStatementAst.Parent if ($null -eq $parentBlock -or -not ($parentBlock.PSObject.Properties.Name -contains 'Statements')) { return @() } $kinds = New-Object System.Collections.Generic.List[string] $statementIndex = -1 for ($i = 0; $i -lt $parentBlock.Statements.Count; $i++) { if ($parentBlock.Statements[$i] -eq $IfStatementAst) { $statementIndex = $i break } } if ($statementIndex -lt 0) { return @() } for ($i = $statementIndex + 1; $i -lt $parentBlock.Statements.Count; $i++) { $kind = Get-OutputKind -StatementAst $parentBlock.Statements[$i] if ($null -eq $kind) { continue } if (-not $kinds.Contains($kind)) { $kinds.Add($kind) } } $kinds } function Get-OutputKind { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.StatementAst] $StatementAst ) $pipelineAst = $null if ($StatementAst -is [System.Management.Automation.Language.PipelineAst]) { $pipelineAst = $StatementAst } elseif ($StatementAst -is [System.Management.Automation.Language.ReturnStatementAst] -and $StatementAst.Pipeline) { $pipelineAst = $StatementAst.Pipeline } if ($null -eq $pipelineAst) { return $null } $element = $pipelineAst.PipelineElements[0] if ($element -is [System.Management.Automation.Language.CommandAst]) { return 'Command' } if ($element -isnot [System.Management.Automation.Language.CommandExpressionAst]) { return 'Other' } $expression = $element.Expression if ($expression -is [System.Management.Automation.Language.ConvertExpressionAst] -and $expression.Type.TypeName.FullName -eq 'pscustomobject') { return 'Object' } if ($expression -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $expression -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) { return 'String' } if ($expression -is [System.Management.Automation.Language.ConstantExpressionAst]) { return 'Scalar' } return 'Other' } Export-ModuleMember -Function Measure-PSAvoidOutputTypeDrift |