src/Markdown.ps1
|
# The Markdown summary (#167): the same run, rendered for a person -- a CI step summary, a pull # request comment. Not a second source of numbers: every figure here is read off the scan and the # gate's verdict, exactly as the JSON report's are. # # Three parts, always in this order: the hotspots (the top N units by cognitive, then cyclomatic), # per-file totals, and -- when given a previous JSON report -- what is new, worse or better than it. # The gate's version adds its verdict and lists EVERY breach, whatever N is: a summary that showed # ten of twelve breaches would be the partial number this project exists to distrust. function ConvertTo-PSCxMarkdownCell { # Text that is safe inside a table cell: a pipe would end the cell, and a line break the row. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] [AllowEmptyString()] [AllowNull()] $Text) return ([string]$Text).Replace('|', '\|').Replace("`r", '').Replace("`n", ' ') } function Get-PSCxMarkdownTable { # A Markdown table: one header row, the separator, one row per entry. Every cell escaped. # Both, for the comma-wrap that keeps the array whole: `, $x` is statically an Object[]. [OutputType([string[]], [object[]])] [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Header, # Each row an array of cells, in header order. [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Row ) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add('| ' + ($Header -join ' | ') + ' |') $lines.Add('|' + (@($Header | ForEach-Object { ' --- ' }) -join '|') + '|') foreach ($r in $Row) { $lines.Add('| ' + (@($r | ForEach-Object { ConvertTo-PSCxMarkdownCell -Text $_ }) -join ' | ') + ' |') } return , [string[]]$lines.ToArray() } function Get-PSCxHotspot { # The Top units by cognitive, then cyclomatic, with file and unit breaking ties so the same # run always lists the same units in the same order. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [int]$Top ) # Emitted, not comma-wrapped: callers collect with @(), and a wrapped empty list would come # back to them as a list holding one empty list. $Unit | Sort-Object @{ Expression = 'Cognitive'; Descending = $true }, @{ Expression = 'Cyclomatic'; Descending = $true }, File, Unit | Select-Object -First $Top } function Get-PSCxFileTotal { # Per file: how many units, the summed cognitive score, and the highest of each metric -- # ordered by the sum, so the file with the most to read comes first. [OutputType([pscustomobject])] [CmdletBinding()] param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit) $totals = foreach ($group in ($Unit | Group-Object File)) { [pscustomobject]@{ File = $group.Name Units = $group.Count Cognitive = [int]($group.Group | Measure-Object Cognitive -Sum).Sum MaxCognitive = [int]($group.Group | Measure-Object Cognitive -Maximum).Maximum MaxCyclomatic = [int]($group.Group | Measure-Object Cyclomatic -Maximum).Maximum } } $totals | Sort-Object @{ Expression = 'Cognitive'; Descending = $true }, File } function Get-PSCxCompareFault { # Why a previous report cannot be compared with this run, as text. Nothing when it can. # # A report from another metric version is refused, as a baseline is: its numbers answer a # different question, so "worse" and "better" against it would be guesses. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] $Document, [Parameter(Mandatory)] [int]$MetricVersion ) if ($Document.generatedFrom -ne 'PSComplexity' -or $null -eq $Document.units) { return 'it is not a PSComplexity JSON report -- write one with -ReportPath' } if ([int]$Document.metricVersion -ne $MetricVersion) { return "it was measured with metric version $($Document.metricVersion) and this run with $MetricVersion, so its numbers are not comparable" } } function Get-PSCxComparison { # This run's units against a previous report's: new (no unit of that file and name before), # worse (either metric up) and better (neither up, at least one down). Units that only # disappeared are not listed; a unit gone is not a hotspot. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$BaseUnit, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) $before = Get-PSCxPolicyLookup -Record $BaseUnit -PathComparison $PathComparison $new = [System.Collections.Generic.List[object]]::new() $worse = [System.Collections.Generic.List[object]]::new() $better = [System.Collections.Generic.List[object]]::new() foreach ($u in $Unit) { $b = $null if (-not $before.TryGetValue((Get-PSCxPolicyKey -File ([string]$u.File) -Unit ([string]$u.Unit)), [ref]$b)) { $new.Add($u); continue } $pair = [pscustomobject]@{ Unit = $u; Before = $b } if ($u.Cyclomatic -gt [int]$b.Cyclomatic -or $u.Cognitive -gt [int]$b.Cognitive) { $worse.Add($pair); continue } if ($u.Cyclomatic -lt [int]$b.Cyclomatic -or $u.Cognitive -lt [int]$b.Cognitive) { $better.Add($pair) } } return [pscustomobject]@{ New = @($new); Worse = @($worse); Better = @($better) } } function Get-PSCxMarkdownHeadline { # The one line a reader takes away: the verdict and its terms, or what was measured. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $Scan, [AllowNull()] $Gate ) $units = @($Scan.Units) $files = @($units | ForEach-Object { $_.File } | Select-Object -Unique).Count $measured = "$($units.Count) unit(s) in $files file(s)" if ($null -eq $Gate) { return "**Measured $measured**, metric version $($Scan.MetricVersion)." } $verdict = $Gate.Passed ? '**Gate passed**' : "**Gate failed** -- $(@($Gate.Violations).Count) unit(s) over a ceiling" return "$verdict. Measured $measured against cyclomatic $($Gate.MaxCyclomatic) and cognitive $($Gate.MaxCognitive)" + "$((@($Gate.Overrides).Count -gt 0) ? ", with $(@($Gate.Overrides).Count) per-path override(s)" : '')." } function Get-PSCxMarkdownScopeLine { # What the numbers do NOT cover, said beside them: a diff-scoped run, excluded files, files # that could not be measured, and what the gate excused. Nothing when there is nothing to say. # Both, for the comma-wrap that keeps the array whole: `, $x` is statically an Object[]. [OutputType([string[]], [object[]])] [CmdletBinding()] param( [Parameter(Mandatory)] $Scan, [AllowNull()] $Gate ) $notes = [System.Collections.Generic.List[string]]::new() if ($null -ne $Scan.Scope.ChangedLines) { $notes.Add("Only the units owning lines changed in $(@($Scan.Scope.ChangedFile).Count) file(s) were gated, not the whole tree.") } elseif ($null -ne $Scan.Scope.ChangedFile) { $notes.Add("Only the $(@($Scan.Scope.ChangedFile).Count) changed file(s) were measured, not the whole tree.") } if (@($Scan.Scope.Excluded).Count -gt 0) { $notes.Add("$(@($Scan.Scope.Excluded).Count) file(s) excluded by the config.") } if (@($Scan.Skipped).Count -gt 0) { $notes.Add("$(@($Scan.Skipped).Count) file(s) could not be measured.") } # Filtered, never @($Gate.X) alone: for a measurement there is no gate, and @() around $null # is a list of ONE, which would report an acceptance nobody made. $accepted = @($Gate.Accepted | Where-Object { $null -ne $_ }).Count $baselined = @($Gate.Baselined | Where-Object { $null -ne $_ }).Count if ($Gate.Config) { $notes.Add("Policy from ``$($Gate.Config)``.") } if ($accepted -gt 0) { $notes.Add("$accepted unit(s) accepted with a written reason.") } if ($baselined -gt 0) { $notes.Add("$baselined unit(s) held to a baseline.") } return , [string[]]$notes.ToArray() } function Get-PSCxMarkdownBreach { # The gate's breaches, every one, with the ceilings each unit faced -- an override's where one # reached it, read from the gate's file ceilings rather than resolved again here. # Both, for the comma-wrap that keeps the array whole: `, $x` is statically an Object[]. [OutputType([string[]], [object[]])] [CmdletBinding()] param([Parameter(Mandatory)] $Gate) # @( ) around the loop: assigned bare, a single breach's row would arrive as its four cells. $rows = @(foreach ($v in @($Gate.Violations)) { $ceiling = $Gate.FileCeiling[[string]$v.File] , @($v.Unit, "$($v.File):$($v.Line)", "$($v.Cyclomatic) / $($ceiling.Cyclomatic ?? $Gate.MaxCyclomatic)", "$($v.Cognitive) / $($ceiling.Cognitive ?? $Gate.MaxCognitive)") }) return , [string[]](@('', '### Over a ceiling', '') + (Get-PSCxMarkdownTable -Header 'Unit', 'Where', 'Cyclomatic / max', 'Cognitive / max' -Row $rows)) } function Get-PSCxMarkdownComparisonPart { # The comparison section: new, worse and better units, each a table, or a line saying none. # Both, for the comma-wrap that keeps the array whole: `, $x` is statically an Object[]. [OutputType([string[]], [object[]])] [CmdletBinding()] param( [Parameter(Mandatory)] $Comparison, [Parameter(Mandatory)] [string]$BaseName ) $lines = [System.Collections.Generic.List[string]]::new() $lines.AddRange([string[]]@('', "### Compared with ``$BaseName``", '')) $lines.Add("$($Comparison.New.Count) new, $($Comparison.Worse.Count) worse, $($Comparison.Better.Count) better.") if ($Comparison.New.Count -gt 0) { $lines.AddRange([string[]]@('', '**New**', '')) $lines.AddRange((Get-PSCxMarkdownTable -Header 'Unit', 'Where', 'Cyclomatic', 'Cognitive' -Row @( $Comparison.New | ForEach-Object { , @($_.Unit, "$($_.File):$($_.Line)", $_.Cyclomatic, $_.Cognitive) }))) } foreach ($part in @(@('Worse', $Comparison.Worse), @('Better', $Comparison.Better))) { if ($part[1].Count -eq 0) { continue } $lines.AddRange([string[]]@('', "**$($part[0])**", '')) $lines.AddRange((Get-PSCxMarkdownTable -Header 'Unit', 'Where', 'Cyclomatic', 'Cognitive' -Row @( $part[1] | ForEach-Object { , @($_.Unit.Unit, "$($_.Unit.File):$($_.Unit.Line)", "$($_.Before.Cyclomatic) -> $($_.Unit.Cyclomatic)", "$($_.Before.Cognitive) -> $($_.Unit.Cognitive)") }))) } return , [string[]]$lines.ToArray() } function Get-PSCxMarkdownDocument { # The whole summary as text. Pure: the previous report arrives already read. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $Scan, [Parameter(Mandatory)] [int]$Top, # The gate's verdict and terms, or $null for a measurement. [AllowNull()] $Gate, # The previous report's comparison, or $null for none. [AllowNull()] $Comparison, [AllowEmptyString()] [string]$BaseName ) $units = @($Scan.Units) $lines = [System.Collections.Generic.List[string]]::new() $lines.AddRange([string[]]@('## PSComplexity', '', (Get-PSCxMarkdownHeadline -Scan $Scan -Gate $Gate))) $scope = Get-PSCxMarkdownScopeLine -Scan $Scan -Gate $Gate if ($scope.Count -gt 0) { $lines.AddRange([string[]]@('', ($scope -join ' '))) } if (@($Gate.Violations | Where-Object { $null -ne $_ }).Count -gt 0) { $lines.AddRange((Get-PSCxMarkdownBreach -Gate $Gate)) } $hot = @(Get-PSCxHotspot -Unit $units -Top $Top) $lines.AddRange([string[]]@('', "### Most complex units (top $Top)", '')) $lines.AddRange((Get-PSCxMarkdownTable -Header 'Unit', 'Where', 'Cognitive', 'Cyclomatic' -Row @( $hot | ForEach-Object { , @($_.Unit, "$($_.File):$($_.Line)", $_.Cognitive, $_.Cyclomatic) }))) $lines.AddRange([string[]]@('', '### Per file', '')) $lines.AddRange((Get-PSCxMarkdownTable -Header 'File', 'Units', 'Cognitive total', 'Max cognitive', 'Max cyclomatic' -Row @( @(Get-PSCxFileTotal -Unit $units) | ForEach-Object { , @($_.File, $_.Units, $_.Cognitive, $_.MaxCognitive, $_.MaxCyclomatic) }))) if ($null -ne $Comparison) { $lines.AddRange((Get-PSCxMarkdownComparisonPart -Comparison $Comparison -BaseName $BaseName)) } return ($lines -join "`n") + "`n" } function Read-PSCxCompareBase { # The previous report to compare against, or $null when none was asked for -- read and checked # BEFORE the scan, so a wrong path is refused before the expensive part rather than after it. [OutputType([object])] [CmdletBinding()] param( # The command's $PSBoundParameters. [Parameter(Mandatory)] [System.Collections.IDictionary]$Bound, [Parameter(Mandatory)] [int]$MetricVersion ) # Refused rather than ignored, as -AllowNew without -UpdateBaseline is: a run carrying these # believes it asked for something, and without a summary to put it in it asked for nothing. if (-not $Bound['MarkdownPath'] -and ($Bound.ContainsKey('MarkdownTop') -or $Bound.ContainsKey('CompareTo'))) { throw '-MarkdownTop and -CompareTo only apply with -MarkdownPath: they shape the Markdown summary, and without one there is nothing for them to shape.' } $path = [string]$Bound['CompareTo'] if (-not $path) { return $null } $document = Read-PSCxDocument -Path $path $fault = Get-PSCxCompareFault -Document $document -MetricVersion $MetricVersion if ($fault) { throw "Cannot compare with $path : $fault." } return $document } function Write-PSCxMarkdown { # Render the summary and write it. The previous report, if any, was read by # Read-PSCxCompareBase before the scan. [OutputType([void])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] $Scan, [Parameter(Mandatory)] [int]$Top, [AllowNull()] $Gate, [AllowNull()] $Base, [AllowEmptyString()] [string]$BaseName ) $comparison = ($null -eq $Base) ? $null : (Get-PSCxComparison -Unit @($Scan.Units) -BaseUnit @($Base.units) -PathComparison $Scan.PathComparison) Save-PSCxText -Path $Path -Text (Get-PSCxMarkdownDocument -Scan $Scan -Top $Top -Gate $Gate -Comparison $comparison -BaseName $BaseName) } |