src/Measure-PSComplexity.ps1
|
<# .SYNOPSIS Public API for PSComplexity: Measure-PSComplexity (data) and Test-PSComplexity (gate). #> function Get-PSCxSourceFile { # Resolve a path (file or directory) to the PowerShell source files to measure. # # [string[]] and not [string], unlike the streaming collectors: this RETURNS the whole # collection as one value rather than emitting items, so the array form is the accurate # declaration. The analyzer says so, which is how the distinction was found. [OutputType([string[]])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Path, [switch]$Recurse) if (Test-Path -LiteralPath $Path -PathType Leaf) { return [string[]]@((Resolve-Path -LiteralPath $Path).Path) } # Take an existing path literally, and as a wildcard only when nothing is there. -Path # glob-parses '[', so a real directory named 'my[1]proj' matches nothing and the scan # reports a clean zero over code it never opened. $gci = @{ File = $true; Recurse = [bool]$Recurse } if (Test-Path -LiteralPath $Path) { $gci.LiteralPath = $Path } else { $gci.Path = $Path } # Filter on the extension rather than with -Include, which a directory ignores unless # -Recurse is also present: a flat folder would resolve to zero files and every number # after it would describe the empty set. return [string[]]@(Get-ChildItem @gci | Where-Object { $_.Extension -in '.ps1', '.psm1' } | ForEach-Object { $_.FullName }) } function Measure-PSComplexity { <# .SYNOPSIS Measure cyclomatic and cognitive complexity of PowerShell code, per unit: each function and filter, each class method, constructor and initialised property, plus one <script-body> per file for top-level code. .DESCRIPTION Parses each .ps1/.psm1 file with the PowerShell AST and reports both metrics. Cognitive complexity implements the SonarSource metric in full (nesting-aware) and extends it for PowerShell constructs the specification does not cover: ForEach-Object and Where-Object score as the loop and conditional they stand in for, && and || as a boolean run, ?? and ??= as a ternary. Cyclomatic is the classic decision-point count, over the same set of constructs. Files that fail to parse are skipped with a warning. .PARAMETER Path One or more files or directories to measure. .PARAMETER Recurse Recurse into subdirectories when a directory is given. .OUTPUTS [pscustomobject] with File, Unit, Line, Cyclomatic, Cognitive -- one per unit. .EXAMPLE Measure-PSComplexity ./src -Recurse | Sort-Object Cognitive -Descending .EXAMPLE # Fail a build if anything is too complex: if (-not (Test-PSComplexity ./src -Recurse)) { exit 1 } #> [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNullOrEmpty()] [string[]]$Path, [switch]$Recurse ) begin { # Resolved paths already emitted. Two inputs can name one file -- a directory and # something inside it, or a wildcard and a literal -- and measuring it twice put # duplicate rows in the output, doubling that file's contribution to anything that # counts. In `begin` rather than `process` so it also spans pipeline input, where # each item arrives as its own invocation of the block below. $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) } process { foreach ($p in $Path) { foreach ($file in (Get-PSCxSourceFile -Path $p -Recurse:$Recurse)) { # OrdinalIgnoreCase: Windows and macOS resolve the same file under different # casing, and a case-sensitive check would let those through as two files. if (-not $seen.Add($file)) { continue } $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$null, [ref]$errors) if ($errors) { # Write-Error, not Write-Warning: non-terminating, so measuring a tree # with one bad file still returns every other file's units -- but it # lands on a stream that -ErrorVariable can capture and CI does not # swallow. Test-PSComplexity captures exactly this and refuses. Write-Error "Skipped '$file' -- parse error: $($errors[0].Message)" continue } $cyc = Get-PSCxCyclomaticMap -Ast $ast $cog = Get-PSCxCognitiveMap -Ast $ast $lines = Get-PSCxUnitTable -Ast $ast # Source order, not hashtable order. .NET enumerates a hashtable by bucket # layout, which is neither insertion nor line order and is not required to # be stable -- so two runs over one unchanged file could emit the same rows # in different sequences. Nothing here noticed, because the gate takes a # max and a human reads a table; a committed baseline or a diffed report # would have. # # Line first, then unit name: two units CAN start on the same line # (`function A { } function B { }`), and a tie left unbroken puts the # nondeterminism straight back. $ordered = $lines.Keys | Sort-Object @{ Expression = { $lines[$_] } }, @{ Expression = { $_ } } foreach ($k in $ordered) { [pscustomobject]@{ File = $file Unit = ($k -replace '@\d+$', '') Line = $lines[$k] Cyclomatic = $cyc[$k] Cognitive = $cog[$k] } } } } } } function Test-PSComplexity { <# .SYNOPSIS Return $true if every unit is at or under the cyclomatic and cognitive ceilings; otherwise $false, writing a warning per offending unit. Intended as a CI gate. .PARAMETER Path One or more files or directories to check. .PARAMETER MaxCyclomatic Cyclomatic ceiling (default 15). .PARAMETER MaxCognitive Cognitive ceiling (default 15). .PARAMETER Recurse Recurse into subdirectories when a directory is given. .OUTPUTS [bool] .EXAMPLE if (-not (Test-PSComplexity ./src -Recurse)) { throw 'Complexity gate failed' } #> [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline, Position = 0)] [ValidateNotNullOrEmpty()] [string[]]$Path, [int]$MaxCyclomatic = 15, [int]$MaxCognitive = 15, [switch]$Recurse ) # ValueFromPipeline needs begin/process/end, not a bare body. A bare body IS the `end` # block, so it would run once with $Path holding only the LAST item piped in -- and the # gate would return a confident verdict about one path while silently ignoring the rest. # Every path is collected first, then judged together, because the verdict is about the # whole selection. begin { $collected = [System.Collections.Generic.List[string]]::new() } process { $collected.AddRange([string[]]$Path) } end { # Parse failures are captured rather than allowed past. A file the gate could not # read is a file it cannot vouch for, and "no unit exceeded a ceiling" is trivially # true of a file that produced no units -- the same shape as passing over an empty # selection. $paths = $collected.ToArray() $units = @(Measure-PSComplexity -Path $paths -Recurse:$Recurse -ErrorVariable parseErrors -ErrorAction SilentlyContinue) if ($parseErrors.Count -gt 0) { throw ("Refusing to vouch for $($parseErrors.Count) file(s) that did not parse: " + (($parseErrors | ForEach-Object { $_.Exception.Message }) -join '; ') + ". Fix the syntax, or exclude the file from the path you gate on.") } # Refuse rather than pass. "No unit breached a ceiling" and "no unit was measured" # are the same $true, so a gate pointed at the wrong place reports clean -- which is # the failure this module exists to find in other people's code. if ($units.Count -eq 0) { $hint = if ($Recurse) { '' } else { ', or add -Recurse if they are in subdirectories' } throw ("Measured no units under: " + ($paths -join ', ') + ". Nothing was checked, " + "so a pass here would describe an empty set. Check the path exists and holds " + ".ps1 or .psm1 files" + $hint + '.') } $violations = @($units | Where-Object { $_.Cyclomatic -gt $MaxCyclomatic -or $_.Cognitive -gt $MaxCognitive }) foreach ($v in $violations) { Write-Warning ("{0}:{1} {2} -- cyclomatic {3} (max {4}), cognitive {5} (max {6})" -f ` $v.File, $v.Line, $v.Unit, $v.Cyclomatic, $MaxCyclomatic, $v.Cognitive, $MaxCognitive) } return $violations.Count -eq 0 } } |