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)

    # AB#6800 -- reuses the pillar rule findings already computed below to attach a WAF maturity
    # level (Microsoft's published 5-level model) alongside the existing percentage score. This
    # is a relabeling of the SAME score, not a second evaluation pass, so it costs nothing and
    # cannot desync from the percentage. Soft dependency: a caller that hasn't dot-sourced
    # Get-MaturityLevel.ps1 still gets scores exactly as before, just without the MaturityLevel
    # property being populated (falls back to $null).
    $maturityAvailable = [bool](Get-Command Get-MaturityLevel -ErrorAction SilentlyContinue)

    $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 }
        # AB#6817 -- carry the framework version through to the score output so any coverage
        # figure this object feeds names the version/date it was measured against. Findings
        # built by fixtures that predate this field simply have no FrameworkVersion property;
        # PSObject.Properties[...] is $null in that case rather than throwing under StrictMode.
        $vf = $_.Group | Where-Object {
            $null -ne $_.PSObject.Properties['FrameworkVersion'] -and $_.FrameworkVersion
        } | Select-Object -First 1
        $version = if ($vf) { $vf.FrameworkVersion } else { $null }
        $areaScore = if ($den -gt 0) { [math]::Round($num / $den * 100, 0, [System.MidpointRounding]::AwayFromZero) } else { $null }
        [pscustomobject]@{
            Framework     = $_.Group[0].Framework
            Area          = $_.Group[0].Area
            Weight        = $weight
            Version       = $version
            Score         = $areaScore
            MaturityLevel = if ($maturityAvailable) { Get-MaturityLevel -Score $areaScore } 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
            # AB#6793 — the third compliance state. Deliberately excluded from `$scorable`
            # above (never a pass, never a fail, never in the score denominator) but still
            # counted and surfaced, the same way Manual/Unknown/Error are, so it is visible
            # rather than silently vanishing from every count.
            NotAssessed = @($_.Group | Where-Object Status -eq 'NotAssessed').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
        # AB#6817 -- same rule at the framework roll-up: the headline score names the version
        # it was measured against, taken from whichever area under it carries one.
        $fv = $_.Group | Where-Object { $_.Version } | Select-Object -First 1
        $fwScore = if ($wsum -gt 0) { [math]::Round($wnum / $wsum, 0, [System.MidpointRounding]::AwayFromZero) } else { $null }
        [pscustomobject]@{
            Framework     = $_.Name
            Score         = $fwScore
            Version       = if ($fv) { $fv.Version } else { $null }
            MaturityLevel = if ($maturityAvailable) { Get-MaturityLevel -Score $fwScore } 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')
        # AB#6793 — kept separate from Errors/Unknown: a control Azure has never evaluated is not
        # a Scout error, and lumping it in there would read as "something broke" rather than "not
        # assessed", which is the wrong signal for an operator deciding whether to assign an
        # initiative.
        NotAssessed = @($Findings | Where-Object Status -eq 'NotAssessed')
        Findings    = @($Findings)
    }
}