src/assess/engine/Get-Score.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Aggregate findings into area/framework scores and a prioritized gap list.
 
.NOTES
    - Manual / Unknown / Error / NA are excluded from the score denominator but
      surfaced as counts so broken content is visible, not silently dropped (AB#5088).
    - Framework score is weighted by each area's AreaWeight, not a flat mean, so a
      3-rule area does not sway the headline like a 40-rule area (AB#5087).
    - Unknown/absent severities sort LAST in the gap list, not first (AB#5089).
    Tracks ADO Story AB#5036 and Bugs AB#5087/#5088/#5089.
#>

function Get-Score {
    param($Findings)

    $statusWeight = @{ Pass = 1.0; Partial = 0.5; Fail = 0.0 }   # Manual/Unknown/Error excluded

    $areas = $Findings | Group-Object Framework, Area | ForEach-Object {
        $scorable = $_.Group | Where-Object { $_.Status -in 'Pass', 'Partial', 'Fail' }
        $den = ($scorable | Measure-Object).Count
        $num = ($scorable | ForEach-Object { $statusWeight[$_.Status] } | Measure-Object -Sum).Sum
        # AreaWeight is uniform within an area; take the first (default 1.0 if absent).
        $weight = 1.0
        $wf = $_.Group | Where-Object { $null -ne $_.PSObject.Properties['AreaWeight'] } | Select-Object -First 1
        if ($wf) { $weight = [double]$wf.AreaWeight }
        [pscustomobject]@{
            Framework = $_.Group[0].Framework
            Area      = $_.Group[0].Area
            Weight    = $weight
            Score     = if ($den -gt 0) { [math]::Round($num / $den * 100, 0, [System.MidpointRounding]::AwayFromZero) } else { $null }
            # @(...) wrap is load-bearing: a Where-Object match of zero items collapses
            # to $null, and $null.Count throws PropertyNotFoundException under
            # Set-StrictMode -Version Latest — @() forces a real (possibly empty) array.
            Pass      = @($_.Group | Where-Object Status -eq 'Pass').Count
            Partial   = @($_.Group | Where-Object Status -eq 'Partial').Count
            Fail      = @($_.Group | Where-Object Status -eq 'Fail').Count
            Manual    = @($_.Group | Where-Object Status -eq 'Manual').Count
            Unknown   = @($_.Group | Where-Object Status -eq 'Unknown').Count
            Error     = @($_.Group | Where-Object Status -eq 'Error').Count
        }
    }

    $frameworks = $areas | Where-Object { $null -ne $_.Score } | Group-Object Framework | ForEach-Object {
        # Weighted average of area scores by AreaWeight (AB#5087).
        $wsum = ($_.Group | ForEach-Object { $_.Weight } | Measure-Object -Sum).Sum
        $wnum = ($_.Group | ForEach-Object { $_.Score * $_.Weight } | Measure-Object -Sum).Sum
        [pscustomobject]@{
            Framework = $_.Name
            Score     = if ($wsum -gt 0) { [math]::Round($wnum / $wsum, 0, [System.MidpointRounding]::AwayFromZero) } else { $null }
            Unknown   = ($_.Group | Measure-Object Unknown -Sum).Sum
            Error     = ($_.Group | Measure-Object Error -Sum).Sum
        }
    }

    # prioritized gap list: fails first, weighted by severity; unknown/missing severity sorts LAST.
    # @(...) wraps below are load-bearing for the same reason as the Pass/Fail/etc.
    # counters above: a pipeline that emits zero objects collapses to $null on
    # assignment, and callers downstream expect a (possibly empty) collection.
    $sevRank = @{ high = 0; medium = 1; low = 2 }
    $gaps = @($Findings | Where-Object Status -eq 'Fail' |
        Sort-Object @{ E = { if ($_.Severity -and $sevRank.ContainsKey($_.Severity)) { $sevRank[$_.Severity] } else { 99 } } }, Area |
        Select-Object Id, Framework, Area, Severity, Title, Remediation)

    [pscustomobject]@{
        GeneratedOn = (Get-Date).ToString('o')
        Frameworks  = @($frameworks)
        Areas       = @($areas)
        Gaps        = $gaps
        Manual      = @($Findings | Where-Object Status -eq 'Manual')
        Errors      = @($Findings | Where-Object Status -in 'Error', 'Unknown')
        Findings    = @($Findings)
    }
}