src/Report.ps1

# The machine-readable forms of a run, and the one place that writes a file.
#
# Two published formats leave this module: our own report, defined by
# schemas/v1/report.schema.json and shipped beside the code, and SARIF 2.1.0, which has its own
# published schema and needs none of ours. Both are built as pure documents and written by a
# single function, so what a report SAYS can be tested without touching a disk.
#
# A report is not a new shape. It is the scan serialised -- Get-PSCxScan already answers what was
# in scope, what was measured and what was skipped -- so nothing here decides anything the
# measurement did not already know.

$script:PSCxSchemaVersion = 1

function Get-PSCxModuleVersion {
    # What goes in the report's producedBy. Prefers the loaded module and falls back to the
    # manifest beside src/, because the suite dot-sources these files without importing a module
    # and a report written from there must still say which version shaped it.
    #
    # Returns the literal 'unknown' when it can find neither, rather than throwing or inventing
    # something plausible. Three options, and the middle one is the trap:
    #
    # throw -- refuses to write the report at all, and "neither" is a REAL configuration:
    # a mutation sandbox copies src/ and tests/ and no manifest, and so does a
    # consumer who dot-sources src/ directly. This function threw, and it turned
    # the sandboxed baseline red before a single mutant ran.
    # '0.0.0' -- worst of the three. It looks like an answer, so every report from every
    # such run claims to come from one release nobody published.
    # 'unknown' -- cannot be mistaken for a version by a reader or by a comparison, and the
    # report still gets written.
    [OutputType([string])]
    [CmdletBinding()]
    param()
    $loaded = @(Get-Module -Name PSComplexity)
    if ($loaded.Count -gt 0) { return "$($loaded[0].Version)" }
    $manifest = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath 'PSComplexity.psd1'
    if (-not (Test-Path -LiteralPath $manifest)) { return 'unknown' }
    return "$((Import-PowerShellDataFile -LiteralPath $manifest).ModuleVersion)"
}

function Get-PSCxReportSummary {
    # The aggregates, over the units actually measured.
    #
    # Every one is reconcilable against the units array in the same document. None of them is the
    # only place a fact lives, which is what lets a consumer disagree with a number rather than
    # take it on trust.
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    [CmdletBinding()]
    param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit)
    # An empty scan is a real outcome -- a path that matched no PowerShell -- and
    # Measure-PSComplexity does not refuse it the way the gate does. It needs no special case:
    # Measure-Object over nothing yields $null, and both the [int] cast and [math]::Round turn
    # that into 0, so every field below is already correct for an empty list.
    #
    # There WAS a special case here, an early return of six zeros. It was deleted because it is
    # indistinguishable from its own absence, which is precisely what the mutation gate reported
    # -- six mutants inside a branch whose removal changes no answer. The counts beside the
    # maxima are what tell "zero complexity" from "nothing measured".
    $cyc = @($Unit | ForEach-Object { $_.Cyclomatic })
    $cog = @($Unit | ForEach-Object { $_.Cognitive })
    return [ordered]@{
        fileCount         = @($Unit | ForEach-Object { $_.File } | Sort-Object -Unique).Count
        unitCount         = $Unit.Count
        # Cast, because Measure-Object returns a double and ConvertTo-Json then writes 9.0
        # where the schema and every reader expect 9. The averages stay real numbers.
        maxCyclomatic     = [int]($cyc | Measure-Object -Maximum).Maximum
        maxCognitive      = [int]($cog | Measure-Object -Maximum).Maximum
        averageCyclomatic = [math]::Round(($cyc | Measure-Object -Average).Average, 2)
        averageCognitive  = [math]::Round(($cog | Measure-Object -Average).Average, 2)
    }
}

function Get-PSCxReportDocument {
    # The report as a document. Pure: the version and the timestamp arrive as parameters rather
    # than being read here, so the whole shape is testable without a clock or a manifest.
    #
    # -Gate is what separates the two published shapes. Absent, this is a measurement and the
    # document carries no verdict -- and the schema FORBIDS one, because Measure-PSComplexity
    # applies no thresholds and a `passed` beside its numbers would be an answer nobody computed.
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Scan,
        [Parameter(Mandatory)] [string]$ModuleVersion,
        [Parameter(Mandatory)] [datetime]$GeneratedAt,
        $Gate
    )
    $mode = 'Measure'
    if ($Gate) { $mode = 'Gate' }
    # Assigned out of the document literal, and typed. A $( ) subexpression unrolls a one-element
    # array to the element, so a run filtered to a single file wrote a bare string where the
    # published schema promises a list.
    $changedFile = $null
    if ($null -ne $Scan.Scope.ChangedFile) { $changedFile = [string[]]@($Scan.Scope.ChangedFile) }
    $document = [ordered]@{
        # Provenance first, so a reader opening the file sees what produced it before what it says.
        generatedFrom = 'PSComplexity'
        schemaVersion = $script:PSCxSchemaVersion
        producedBy    = [ordered]@{ module = 'PSComplexity'; version = "$ModuleVersion" }
        # Round-trippable and sortable as text, and UTC so reports from two machines compare
        # without knowing where either ran.
        generatedAt   = $GeneratedAt.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
        mode          = $mode
        # Required by the schema, and the reason is the whole point: the metric has already moved
        # twice for source that did not change, so a stored number that cannot be checked for
        # comparability is a trend chart waiting to mislead.
        metricVersion = $Scan.MetricVersion
        scope         = [ordered]@{
            path    = @($Scan.Scope.Path)
            recurse = [bool]$Scan.Scope.Recurse
            # Forward slashes, like every File in the same document. A published format that
            # spells one path two ways makes a consumer guess which one it is holding.
            root    = ([string]$Scan.Scope.Root).Replace([System.IO.Path]::DirectorySeparatorChar, '/')
            # null for a whole-tree run, an array for a diff-scoped one. A consumer reading a
            # score has to be able to tell which subset it describes, and absent is a different
            # answer from empty: only one of them may be read as a full measurement.
            changedFile = $changedFile
        }
        # Beside the summary, never instead of it: an aggregate that cannot say what it excluded
        # is the failure this project exists to find in other people's code.
        skipped       = @($Scan.Skipped | ForEach-Object { [ordered]@{ file = $_.File; reason = $_.Reason } })
        summary       = Get-PSCxReportSummary -Unit @($Scan.Units)
        units         = @($Scan.Units)
    }
    if ($Gate) {
        $document['thresholds'] = [ordered]@{
            cyclomatic = $Gate.MaxCyclomatic
            cognitive  = $Gate.MaxCognitive
        }
        $document['passed'] = [bool]$Gate.Passed
        $document['violations'] = @($Gate.Violations)
        # What the run EXCUSED, with the argument for each. A gate report that reported a pass
        # without saying what it excused would be the mute button the acceptance concept exists
        # instead of.
        $document['accepted'] = @($Gate.Accepted | ForEach-Object {
                [ordered]@{ file = [string]$_.File; unit = [string]$_.Unit; reason = [string]$_.Reason }
            })
        # And what the BASELINE excused, by the same argument. A baseline routinely excuses far
        # more units than an acceptance ever will, so a report that recorded one and not the other
        # would say "passed" over a set it never named -- the shape of aggregate this project
        # exists to distrust. Each carries the score it was held to, not just its name, because
        # "excused" without a number does not say how much room was left.
        $document['baselined'] = @($Gate.Baselined | ForEach-Object {
                [ordered]@{
                    file       = [string]$_.File
                    unit       = [string]$_.Unit
                    cyclomatic = [int]$_.Cyclomatic
                    cognitive  = [int]$_.Cognitive
                }
            })
    }
    return $document
}

function Get-PSCxSarifRule {
    # One SARIF rule per metric, so a unit over both produces two results.
    #
    # Two rules rather than one: they render against the same line but suppress independently,
    # and a team that has decided cyclomatic is not their problem should not have to silence
    # cognitive to say so.
    [OutputType([object[]])]
    [CmdletBinding()]
    param()
    return @(
        [ordered]@{
            id               = 'PSCxCyclomatic'
            name             = 'CyclomaticComplexity'
            shortDescription = [ordered]@{ text = 'Cyclomatic complexity exceeds the configured ceiling.' }
            fullDescription  = [ordered]@{ text = 'Cyclomatic complexity counts decision points: each if/elseif, switch clause, loop, catch, trap, ternary and each -and/-or. It is a count of paths, not of how hard the code is to follow.' }
            helpUri          = 'https://github.com/Fortigi/PSComplexity#the-two-metrics'
        }
        [ordered]@{
            id               = 'PSCxCognitive'
            name             = 'CognitiveComplexity'
            shortDescription = [ordered]@{ text = 'Cognitive complexity exceeds the configured ceiling.' }
            fullDescription  = [ordered]@{ text = 'Cognitive complexity follows the SonarSource rules: structures score one each, and nesting adds its depth on top. It tracks how hard code is to follow rather than how many paths it has.' }
            helpUri          = 'https://github.com/Fortigi/PSComplexity#the-two-metrics'
        }
    )
}

function Get-PSCxSarifResult {
    # One result per breached ceiling for one unit: none, one, or two.
    #
    # Declares what a caller RECEIVES -- individual results, streamed -- rather than an array of
    # them, which is a single return value this never produces.
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Unit,
        [Parameter(Mandatory)] [int]$MaxCyclomatic,
        [Parameter(Mandatory)] [int]$MaxCognitive
    )
    $breaches = @()
    if ($Unit.Cyclomatic -gt $MaxCyclomatic) {
        $breaches += , @('PSCxCyclomatic', 0, 'cyclomatic', $Unit.Cyclomatic, $MaxCyclomatic)
    }
    if ($Unit.Cognitive -gt $MaxCognitive) {
        $breaches += , @('PSCxCognitive', 1, 'cognitive', $Unit.Cognitive, $MaxCognitive)
    }
    foreach ($b in $breaches) {
        [ordered]@{
            ruleId              = $b[0]
            ruleIndex           = $b[1]
            # error, not warning: this run failed a gate on it. A finding that renders as advice
            # while the build goes red says two different things about one fact.
            level               = 'error'
            message             = [ordered]@{ text = "$($Unit.Unit) has $($b[2]) complexity $($b[3]), over the ceiling of $($b[4])." }
            locations           = @(
                [ordered]@{
                    physicalLocation = [ordered]@{
                        # Forward slashes and relative to the repo, which is what code scanning
                        # matches against the checkout. Measure-PSComplexity already produces
                        # File in exactly that form.
                        artifactLocation = [ordered]@{ uri = $Unit.File }
                        region           = [ordered]@{ startLine = $Unit.Line }
                    }
                }
            )
            # File and Unit, never Line. Line is explicitly not an identity in this project -- it
            # moves whenever anything above the unit is edited -- and a fingerprint built on it
            # would close and reopen the same finding on an unrelated edit above it.
            partialFingerprints = [ordered]@{ psComplexityUnit = "$($Unit.File)|$($Unit.Unit)" }
        }
    }
}

function Get-PSCxSarifDocument {
    # A SARIF 2.1.0 log for the units this run failed on.
    #
    # Written by the gate and not by measurement: without thresholds there is no such thing as a
    # finding, and a SARIF file from a run that applied no ceilings would be an empty results
    # array claiming a clean bill of health.
    #
    # An accepted unit produces no result. The gate excused it, and the argument is not lost --
    # the JSON report carries it under `accepted`, which is where "what did this run excuse"
    # is answered.
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Violation,
        [Parameter(Mandatory)] [string]$ModuleVersion,
        [Parameter(Mandatory)] [int]$MaxCyclomatic,
        [Parameter(Mandatory)] [int]$MaxCognitive
    )
    $results = @(foreach ($u in $Violation) {
            Get-PSCxSarifResult -Unit $u -MaxCyclomatic $MaxCyclomatic -MaxCognitive $MaxCognitive
        })
    return [ordered]@{
        '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json'
        version   = '2.1.0'
        runs      = @(
            [ordered]@{
                tool    = [ordered]@{
                    driver = [ordered]@{
                        name           = 'PSComplexity'
                        version        = "$ModuleVersion"
                        informationUri = 'https://github.com/Fortigi/PSComplexity'
                        rules          = Get-PSCxSarifRule
                    }
                }
                results = $results
            }
        )
    }
}

function Write-PSCxGateArtifact {
    # The gate's optional files, decided in one place so Test-PSComplexity stays a thin
    # predicate: two more branches in its end block would push it toward the ceiling this
    # module gates itself on, for wiring rather than for a decision.
    #
    # Both paths are optional and independent -- a team may want the SARIF for its pull requests
    # and no JSON, or the JSON for a trend and no SARIF.
    [OutputType([void])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Scan,
        [Parameter(Mandatory)] [bool]$Passed,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Violation,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Accept,
        [Parameter(Mandatory)] [int]$MaxCyclomatic,
        [Parameter(Mandatory)] [int]$MaxCognitive,
        # Optional so a run with no baseline records an empty list rather than nothing: absent and
        # empty are different answers, and a consumer iterating this should not have to tell them
        # apart.
        [AllowEmptyCollection()] [object[]]$Baselined = @(),
        [AllowEmptyString()] [string]$ReportPath,
        [AllowEmptyString()] [string]$SarifPath
    )
    if ($ReportPath) {
        $gate = [pscustomobject]@{
            MaxCyclomatic = $MaxCyclomatic
            MaxCognitive  = $MaxCognitive
            Passed        = $Passed
            Violations    = $Violation
            Accepted      = $Accept
            Baselined     = $Baselined
        }
        Save-PSCxDocument -Path $ReportPath -Document (Get-PSCxReportDocument -Scan $Scan `
                -ModuleVersion (Get-PSCxModuleVersion) -GeneratedAt (Get-Date) -Gate $gate)
    }
    if ($SarifPath) {
        Save-PSCxDocument -Path $SarifPath -Document (Get-PSCxSarifDocument -Violation $Violation `
                -ModuleVersion (Get-PSCxModuleVersion) -MaxCyclomatic $MaxCyclomatic -MaxCognitive $MaxCognitive)
    }
}

function Save-PSCxDocument {
    # The only function here that touches a disk, so everything above stays testable without one.
    [OutputType([void])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Document,
        [Parameter(Mandatory)] [string]$Path
    )
    $parent = Split-Path -Parent $Path
    # A report path pointing into a directory that does not exist yet is an ordinary thing to
    # ask for -- reports/ on a fresh clone, or an artifacts directory a CI step has not made.
    if ($parent) { [System.IO.Directory]::CreateDirectory($parent) | Out-Null }
    # Deep enough for SARIF, which is the deeper of the two formats by a long way: a result
    # reaches root > runs > run > results > result > locations > location > physicalLocation >
    # artifactLocation > uri, nine levels down. Our own report needs six.
    #
    # ConvertTo-Json truncates past its depth SILENTLY, replacing the tail with the .NET type
    # name -- so the file stays valid JSON and stops matching its schema, which is the shape of
    # bug this module exists to find. Depth 6 shipped exactly that here: every SARIF location
    # was the string "System.Collections.Specialized.OrderedDictionary".
    # WarningAction, because PowerShell's own truncation warning is redundant beside the
    # throw below and far less actionable -- it names a depth, not the file it ruined.
    $json = $Document | ConvertTo-Json -Depth 12 -WarningAction SilentlyContinue
    # Asserted rather than trusted, because raising the depth fixes today's document and the
    # next nested field silently reintroduces it. A type name where a value belongs is never
    # something this module means to write.
    foreach ($marker in 'System.Collections.Specialized.OrderedDictionary', 'System.Object[]', 'System.Collections.Hashtable') {
        if ($json.Contains($marker)) {
            throw "Refusing to write $Path : ConvertTo-Json truncated the document and left '$marker' where a value belongs. Raise the depth in Save-PSCxDocument."
        }
    }
    # ErrorAction Stop because Set-Content fails non-terminatingly by default: an unwritable
    # path would otherwise leave the run green and the artefact absent.
    $json | Set-Content -LiteralPath $Path -Encoding utf8 -ErrorAction Stop
}

function Read-PSCxDocument {
    # A JSON document off disk, or a throw naming the path and what was wrong with it.
    #
    # The counterpart to Save-PSCxDocument, and here for the same reason: this file owns document
    # I/O so that everything deciding what a document MEANS stays testable without a disk.
    #
    # Three distinct failures, named separately, because a baseline path is the one thing a
    # consumer types by hand and a single "could not read" sends them to check the wrong one. A
    # missing file is usually a wrong path; a parse failure is usually a hand edit.
    [OutputType([object])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Path
    )
    # LiteralPath throughout: a baseline living under a directory with [ or ] in its name is
    # ordinary on Windows, and the wildcard form would report it as missing.
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw "No such file: $Path"
    }
    $text = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
    # An empty file parses to $null rather than failing, and $null would then be read as a
    # document with no entries -- a baseline permitting nothing, which is a silent verdict
    # change rather than an error.
    if (-not $text -or -not $text.Trim()) {
        throw "$Path is empty. An empty baseline is a document with an empty units array, not an empty file."
    }
    try {
        return $text | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "$Path is not valid JSON: $($_.Exception.Message)"
    }
}