src/assess/engine/Get-GovernanceDomainScore.ps1

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

<#
.SYNOPSIS
    Convert the Cloud Governance framework's per-domain 0-100 percentage scores (already
    produced by Get-Score) into a 1-10 domain maturity score, one per CAF Govern risk
    category (AB#6459, Feature AB#6458, Epic AB#6454).
 
.DESCRIPTION
    Get-Score already computes a weighted-mean 0-100 percentage score per (Framework, Area)
    group from Pass/Partial/Fail findings. This function does NOT run a second scoring pass
    or duplicate any rule -- it takes the SAME Areas Get-Score already produced (filtered to
    Framework -eq 'Cloud Governance', the seven caf.govern.*.yaml rule files) and relabels
    each area's percentage onto a 1-10 scale.
 
    Why 1-10 and not something reused: CAF's Govern methodology publishes no maturity model
    or level scheme at all (unlike WAF, which publishes an explicit 5-level model that
    Get-MaturityLevel.ps1 already buckets scores into). There is nothing Microsoft-published
    to relabel here, so the 1-10 scale is entirely Scout's own invention -- see
    docs/design/governance-domain-maturity-scale.md for the full reconciliation with
    Get-MaturityLevel's WAF 5-level model, which this is deliberately NOT the same scale as.
 
    Mapping: Score1To10 = Max(1, Min(10, Round(PercentScore / 10))). A 1-10 scale (not 0-10)
    is used because "1" reads as "minimally present" and a governance domain that was
    actually SCORED (at least one Pass/Partial/Fail rule) always has some evidence, even if
    every rule failed -- 0% still floors to 1, not 0, so the number never collapses to
    "looks like nothing was measured".
 
    NotAssessed (AB#6844/AB#6845 false-pass class): a domain where every rule in it is
    Manual/Unknown/Error (Get-Score's denominator is 0, so PercentScore is $null) renders
    Score = $null and NotAssessed = $true here -- never a fabricated 1 or a misleading 10.
    A caller MUST check NotAssessed before displaying Score, exactly as Get-Score's own
    $null-Score convention already requires downstream.
 
.PARAMETER Areas
    The Areas array from Get-Score's output (or any array of objects carrying Area/Score/
    Pass/Partial/Fail/Manual/Unknown/Error).
 
.PARAMETER Framework
    The framework name to filter to. Defaults to 'Cloud Governance' (the caf.govern.*.yaml
    rule files' `framework:` value); overridable for tests.
 
.NOTES
    Tracks ADO Story AB#6459 (Feature AB#6458, Epic AB#6454).
#>

function ConvertTo-ScoutGovernanceScale {
    # Shared 0-100 -> 1-10 mapping, factored out so the per-domain conversion below and any
    # overall/headline conversion (Export-GovernanceReport.ps1) use the exact same arithmetic
    # and can never drift apart.
    param([AllowNull()][Nullable[double]] $PercentScore)
    if ($null -eq $PercentScore) { return $null }
    return [Math]::Max(1, [Math]::Min(10, [Math]::Round($PercentScore / 10.0, 0, [System.MidpointRounding]::AwayFromZero)))
}

function Get-GovernanceDomainScore {
    param(
        [Parameter(Mandatory)] $Areas,
        [string] $Framework = 'Cloud Governance'
    )

    # @() wrap is load-bearing: Where-Object over zero matches collapses to $null on
    # assignment, and $null.Count throws PropertyNotFoundException under
    # Set-StrictMode -Version Latest (the same pattern documented throughout Get-Score.ps1).
    $domains = @($Areas | Where-Object { $_.Framework -eq $Framework })

    # Write-Output -NoEnumerate, NOT `return , @(...)`: the comma operator wraps an ALREADY
    # zero-length array into a ONE-element array whose sole element is the empty array (a
    # classic PowerShell gotcha) -- an empty $Areas input would then return Count -eq 1 with
    # that one "domain" carrying no Area/Score properties at all, throwing under StrictMode
    # the moment a caller reads .Area. -NoEnumerate preserves a real, possibly-empty array's
    # identity across the return boundary instead (the same fix Resolve-JsonPath.ps1 uses).
    $result = @($domains | Sort-Object Area | ForEach-Object {
            $pct = $_.Score
            $notAssessed = ($null -eq $pct)
            $score1To10 = ConvertTo-ScoutGovernanceScale -PercentScore $pct
            [pscustomobject]@{
                Area         = $_.Area
                PercentScore = $pct
                Score        = $score1To10
                NotAssessed  = $notAssessed
                # Carried through unchanged from Get-Score's Areas entry so a renderer never
                # has to re-derive them (and can never desync from the percentage score).
                Pass         = $_.Pass
                Partial      = $_.Partial
                Fail         = $_.Fail
                Manual       = $_.Manual
                Unknown      = $_.Unknown
                Error        = $_.Error
            }
        })
    Write-Output -NoEnumerate $result
}