Skills/pester-patterns/scripts/Find-PesterV4Pattern.ps1
|
#requires -Version 5.1 using namespace System.Management.Automation.Language <# .SYNOPSIS Reports Pester v4 constructs that change behaviour under the Pester 5 two-phase engine. .DESCRIPTION Scans Pester test files and emits one object per construct that either fails outright under Pester 5 or, worse, still passes while testing something other than what it claims. The scan is a report, not a gate. Several findings are legal Pester 5 - discovery-time code feeding -ForEach is the obvious one - so a human adjudicates each finding rather than the script rewriting it. Detected constructs: LegacyShould Should Be x, Should Throw x (no dash) LegacyMockAssertion Assert-MockCalled, Assert-VerifiableMock(s) InModuleScopeWrapper Describe/Context/It wrapped in InModuleScope LegacyPathDiscovery $MyInvocation.MyCommand.Path/Definition TopLevelCommand a command running at file top level BlockBodyCommand a command in a Describe/Context body DeprecatedParameter Invoke-Pester -Script/-TestName/-Show/... ParseError the file does not parse at all .PARAMETER Path File or directory to scan. Directories are searched recursively. Defaults to the current location. .PARAMETER Filter Wildcard applied when Path is a directory. Defaults to *.Tests.ps1. .EXAMPLE ./Find-PesterV4Pattern.ps1 -Path ./tests Lists every v4 construct under ./tests. .EXAMPLE ./Find-PesterV4Pattern.ps1 -Path ./tests | Group-Object -Property Construct | Sort-Object -Property Count -Descending Sizes the migration before starting it. .OUTPUTS PSCustomObject with Path, Line, Column, Construct, Detail, Guidance. #> [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Position = 0)] [ValidateNotNullOrEmpty()] [string]$Path = '.', [Parameter()] [ValidateNotNullOrEmpty()] [string]$Filter = '*.Tests.ps1' ) $ErrorActionPreference = 'Stop' # Legal directly inside a Describe or Context body under Pester 5. $script:blockCommand = @( 'Describe' 'Context' 'It' 'BeforeAll' 'AfterAll' 'BeforeEach' 'AfterEach' 'BeforeDiscovery' ) # Legal at file top level. It and the *Each blocks are not: they have no # containing block to attach to. $script:topLevelCommand = @( 'Describe' 'BeforeAll' 'AfterAll' 'BeforeDiscovery' ) function New-Finding { [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Mandatory)] [string]$FilePath, [Parameter(Mandatory)] [AllowNull()] [IScriptExtent]$Extent, [Parameter(Mandatory)] [string]$Construct, [Parameter(Mandatory)] [string]$Detail, [Parameter(Mandatory)] [string]$Guidance ) [pscustomobject]@{ Path = $FilePath Line = if ($Extent) { $Extent.StartLineNumber } else { 0 } Column = if ($Extent) { $Extent.StartColumnNumber } else { 0 } Construct = $Construct Detail = $Detail Guidance = $Guidance } } <# Returns the CommandAst a statement invokes, or $null when the statement is not a single bare command. A command call arrives as a PipelineAst wrapping one CommandAst, so testing the statement itself never matches. #> function Resolve-StatementCommand { [CmdletBinding()] [OutputType([CommandAst])] param ( [Parameter(Mandatory)] [StatementAst]$Statement ) if ($Statement -is [CommandAst]) { return $Statement } if ($Statement -is [PipelineAst] -and $Statement.PipelineElements.Count -eq 1 -and $Statement.PipelineElements[0] -is [CommandAst]) { return $Statement.PipelineElements[0] } return $null } <# Emits a finding for every statement that runs a command where only Pester block commands belong. A pure assignment is left alone: it executes during Discovery, which is how -ForEach data is legitimately built. #> function Find-DisallowedStatement { [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Mandatory)] [string]$FilePath, [Parameter(Mandatory)] [AllowEmptyCollection()] [StatementAst[]]$Statement, [Parameter(Mandatory)] [string[]]$AllowedCommand, [Parameter(Mandatory)] [ValidateSet('TopLevelCommand', 'BlockBodyCommand')] [string]$Construct, [Parameter(Mandatory)] [string]$Guidance ) foreach ($currentStatement in $Statement) { if ($currentStatement -is [FunctionDefinitionAst]) { New-Finding -FilePath $FilePath -Extent $currentStatement.Extent -Construct $Construct ` -Detail ('function {0}' -f $currentStatement.Name) ` -Guidance 'Pester 5 runs each It in its own runspace; define helpers in BeforeAll.' continue } $statementCommand = Resolve-StatementCommand -Statement $currentStatement if ($statementCommand -and $statementCommand.GetCommandName() -in $AllowedCommand) { continue } $command = $currentStatement.FindAll( { param ($node) $node -is [CommandAst] }, $true ) foreach ($currentCommand in $command) { $commandName = $currentCommand.GetCommandName() if (-not $commandName -or $commandName -in $script:blockCommand) { continue } New-Finding -FilePath $FilePath -Extent $currentCommand.Extent -Construct $Construct ` -Detail $commandName -Guidance $Guidance break } } } function Find-FilePattern { [CmdletBinding()] [OutputType([pscustomobject])] param ( [Parameter(Mandatory)] [string]$FilePath ) $token = $null $parseError = $null $ast = [Parser]::ParseFile($FilePath, [ref]$token, [ref]$parseError) if ($parseError -and $parseError.Count -gt 0) { foreach ($currentError in $parseError) { New-Finding -FilePath $FilePath -Extent $currentError.Extent -Construct 'ParseError' ` -Detail $currentError.Message ` -Guidance 'Fix the syntax before migrating; the rest of this file was not scanned.' } return } $command = $ast.FindAll({ param ($node) $node -is [CommandAst] }, $true) foreach ($currentCommand in $command) { $commandName = $currentCommand.GetCommandName() if (-not $commandName) { continue } switch -Regex ($commandName) { '^Should$' { if ($currentCommand.CommandElements.Count -ge 2 -and $currentCommand.CommandElements[1] -isnot [CommandParameterAst]) { New-Finding -FilePath $FilePath -Extent $currentCommand.Extent -Construct 'LegacyShould' ` -Detail $currentCommand.Extent.Text ` -Guidance 'Use the dashed form. Should -Throw matches with -like, so wrap the expected text in *.' } } '^Assert-(MockCalled|VerifiableMocks?)$' { $replacement = if ($commandName -eq 'Assert-MockCalled') { 'Should -Invoke' } else { 'Should -InvokeVerifiable' } New-Finding -FilePath $FilePath -Extent $currentCommand.Extent -Construct 'LegacyMockAssertion' ` -Detail $commandName ` -Guidance ('Replace with {0}; re-check -Times after the scope changes.' -f $replacement) } '^InModuleScope$' { $body = $currentCommand.CommandElements | Where-Object -FilterScript { $_ -is [ScriptBlockExpressionAst] } foreach ($currentBody in $body) { $wrapped = $currentBody.FindAll( { param ($node) $node -is [CommandAst] -and $node.GetCommandName() -in @('Describe', 'Context', 'It') }, $true ) if ($wrapped.Count -gt 0) { New-Finding -FilePath $FilePath -Extent $currentCommand.Extent -Construct 'InModuleScopeWrapper' ` -Detail ('wraps {0}' -f $wrapped[0].GetCommandName()) ` -Guidance 'Prefer Mock -ModuleName. Keep InModuleScope inside an It, never around a block.' } } } '^Invoke-Pester$' { $deprecated = $currentCommand.CommandElements | Where-Object -FilterScript { $_ -is [CommandParameterAst] -and $_.ParameterName -in @('Script', 'TestName', 'Show', 'PesterOption', 'Strict') } foreach ($currentParameter in $deprecated) { New-Finding -FilePath $FilePath -Extent $currentParameter.Extent -Construct 'DeprecatedParameter' ` -Detail ('-{0}' -f $currentParameter.ParameterName) ` -Guidance 'Move to New-PesterConfiguration; -Script becomes -Path and -TestName becomes -FullNameFilter.' } } } if ($commandName -in @('Describe', 'Context')) { $body = $currentCommand.CommandElements | Where-Object -FilterScript { $_ -is [ScriptBlockExpressionAst] } | Select-Object -Last 1 if ($body -and $body.ScriptBlock.EndBlock) { Find-DisallowedStatement -FilePath $FilePath -Statement $body.ScriptBlock.EndBlock.Statements ` -AllowedCommand $script:blockCommand -Construct 'BlockBodyCommand' ` -Guidance 'Code in a block body runs during Discovery. Move setup to BeforeAll, -ForEach data to BeforeDiscovery.' } } } $member = $ast.FindAll({ param ($node) $node -is [MemberExpressionAst] }, $true) foreach ($currentMember in $member) { if ($currentMember.Extent.Text -match '\$MyInvocation\.MyCommand\.(Path|Definition)') { New-Finding -FilePath $FilePath -Extent $currentMember.Extent -Construct 'LegacyPathDiscovery' ` -Detail $currentMember.Extent.Text ` -Guidance 'Returns nothing inside a Pester 5 BeforeAll. Use $PSScriptRoot or $PSCommandPath.' } } if ($ast.EndBlock) { Find-DisallowedStatement -FilePath $FilePath -Statement $ast.EndBlock.Statements ` -AllowedCommand $script:topLevelCommand -Construct 'TopLevelCommand' ` -Guidance 'Runs during Discovery on every file. Legal only when it builds -ForEach data; otherwise move it into BeforeAll.' } } $resolvedPath = Resolve-Path -LiteralPath $Path | Select-Object -ExpandProperty Path $targetFile = if (Test-Path -LiteralPath $resolvedPath -PathType Container) { Get-ChildItem -LiteralPath $resolvedPath -Filter $Filter -File -Recurse } else { Get-Item -LiteralPath $resolvedPath } if (-not $targetFile) { Write-Warning -Message ("No file matching '{0}' found under '{1}'." -f $Filter, $resolvedPath) return } foreach ($currentFile in $targetFile) { Write-Verbose -Message ('Scanning {0}' -f $currentFile.FullName) Find-FilePattern -FilePath $currentFile.FullName } |