Rules/PSAvoidPropertyShapeDrift.psm1
|
Set-StrictMode -Version Latest $script:RuleName = 'PSAvoidPropertyShapeDrift' $script:RuleMessage = 'Avoid changing direct PSCustomObject property shape across switch-driven branches in pipeline-facing commands. Keep emitted object properties stable so pipeline consumers can rely on one shape.' function Measure-PSAvoidPropertyShapeDrift { [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-HasPropertyShapeDrift -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-HasPropertyShapeDrift { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.FunctionDefinitionAst] $FunctionAst ) $switchParameterNames = @(Get-SwitchParameterNames -FunctionAst $FunctionAst) if ($switchParameterNames.Count -eq 0) { return $false } $ifStatements = @( $FunctionAst.FindAll({ param($Ast) $Ast -is [System.Management.Automation.Language.IfStatementAst] }, $true) ) foreach ($ifStatement in $ifStatements) { if (-not (Test-IfStatementUsesSwitchCondition -IfStatementAst $ifStatement -SwitchParameterNames $switchParameterNames)) { continue } $propertySets = @(Get-IfStatementPropertySets -IfStatementAst $ifStatement) if ($propertySets.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 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-IfStatementPropertySets { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.IfStatementAst] $IfStatementAst ) $propertySets = New-Object System.Collections.Generic.List[string] foreach ($clause in $IfStatementAst.Clauses) { foreach ($propertySet in @(Get-BranchPropertySets -StatementBlockAst $clause.Item2)) { if (-not $propertySets.Contains($propertySet)) { $propertySets.Add($propertySet) } } } if ($IfStatementAst.ElseClause) { foreach ($propertySet in @(Get-BranchPropertySets -StatementBlockAst $IfStatementAst.ElseClause)) { if (-not $propertySets.Contains($propertySet)) { $propertySets.Add($propertySet) } } } else { foreach ($propertySet in @(Get-ContinuationPropertySets -IfStatementAst $IfStatementAst)) { if (-not $propertySets.Contains($propertySet)) { $propertySets.Add($propertySet) } } } $propertySets } function Get-BranchPropertySets { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.Ast] $StatementBlockAst ) $propertySets = New-Object System.Collections.Generic.List[string] foreach ($statement in $StatementBlockAst.Statements) { $propertySet = Get-PropertySetSignature -StatementAst $statement if ($null -eq $propertySet) { continue } if (-not $propertySets.Contains($propertySet)) { $propertySets.Add($propertySet) } } $propertySets } function Get-ContinuationPropertySets { [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 @() } $propertySets = 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++) { $propertySet = Get-PropertySetSignature -StatementAst $parentBlock.Statements[$i] if ($null -eq $propertySet) { continue } if (-not $propertySets.Contains($propertySet)) { $propertySets.Add($propertySet) } } $propertySets } function Get-PropertySetSignature { [CmdletBinding()] param( [Parameter(Mandatory)] [System.Management.Automation.Language.StatementAst] $StatementAst ) $propertySet = @(Get-DirectObjectPropertySet -StatementAst $StatementAst) if ($propertySet.Count -eq 0) { return $null } $propertySet -join '|' } function Get-DirectObjectPropertySet { [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 -or $pipelineAst.PipelineElements.Count -ne 1) { return @() } $element = $pipelineAst.PipelineElements[0] if ($element -isnot [System.Management.Automation.Language.CommandExpressionAst]) { return @() } $expression = $element.Expression if ($expression -isnot [System.Management.Automation.Language.ConvertExpressionAst]) { return @() } if ($expression.Type.TypeName.FullName -ne 'pscustomobject') { return @() } if ($expression.Child -isnot [System.Management.Automation.Language.HashtableAst]) { return @() } @( $expression.Child.KeyValuePairs | ForEach-Object { $_.Item1.SafeGetValue() } ) } Export-ModuleMember -Function Measure-PSAvoidPropertyShapeDrift |