internal/functions/Get-BuiltInVersionStatus.ps1

function Get-BuiltInVersionStatus {
    <#
    .SYNOPSIS
    Determines the definition version status of a single assignment against the latest published
    version of the built-in Policy or Policy Set definition it references.
 
    .DESCRIPTION
    Azure pins an assignment to a major version whether or not the EPAC definition files ask for it.
    When an assignment is created without definitionVersion, Azure stamps '{latestMajor}.*.*' server
    side, so the assignment silently follows minor and patch updates but never moves to a new major
    version. An assignment can therefore fall behind a major version without EPAC reporting any drift,
    because the pinned version still matches what is deployed.
 
    This evaluates every assignment, not only the ones which pin a version in the definition files. The
    effective version is resolved in this order:
 
      1. definitionVersion from the assignment definition files.
      2. definitionVersion stamped on the deployed assignment by Azure.
      3. Nothing, which means the assignment is new and will be stamped with the latest major version
         when it is created.
 
    The latest version is read from the definition already loaded by EPAC, so no extra Azure calls are
    made. Azure also exposes effectiveDefinitionVersion and latestDefinitionVersion, but only through
    an ARM $expand on a per assignment GET, which Azure Resource Graph does not return and which would
    cost one REST call per assignment.
 
    Only major versions are compared. Minor and patch updates are ingested automatically by the
    wildcard Azure applies, so reporting them would be noise.
 
    .PARAMETER PolicyDefinitionId
    Resource id of the Policy or Policy Set definition referenced by the assignment.
 
    .PARAMETER DefinitionVersion
    The version pinned by the assignment definition files, if any.
 
    .PARAMETER DeployedDefinitionVersion
    The definitionVersion stamped on the deployed assignment, if the assignment already exists.
 
    .PARAMETER PolicyDefinition
    The latest definition object as loaded by EPAC, used to read policyType and the published version.
 
    .OUTPUTS
    Hashtable describing the version status. The status field is one of:
      updateAvailable - a newer major version of the built-in is published
      current - the assignment is on the latest major version
      tracksLatest - no version is pinned yet, so the assignment will take the latest major version
      custom - the definition is not a built-in, so EPAC versions it in the repo
      unknown - the version could not be determined and no advisory can be given
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string] $PolicyDefinitionId,

        [Parameter(Mandatory = $false)]
        [string] $DefinitionVersion,

        [Parameter(Mandatory = $false)]
        [string] $DeployedDefinitionVersion,

        [Parameter(Mandatory = $false)]
        $PolicyDefinition
    )

    $status = @{
        policyDefinitionId = $PolicyDefinitionId
        displayName        = $null
        status             = "unknown"
        updateAvailable    = $false
        assignedVersion    = $null
        assignedMajor      = $null
        assignedVersionFrom = "none"
        latestVersion      = $null
        latestMajor        = $null
    }

    if ($null -eq $PolicyDefinition) {
        # The referenced definition was not loaded, so nothing can be said about its version
        return $status
    }

    $properties = Get-PolicyResourceProperties -PolicyResource $PolicyDefinition

    $displayName = $properties.displayName
    if ([string]::IsNullOrWhiteSpace($displayName)) {
        $displayName = $PolicyDefinition.name
    }
    $status.displayName = $displayName

    if ($properties.policyType -ne "BuiltIn") {
        # Custom definitions are versioned by the EPAC repo itself
        $status.status = "custom"
        return $status
    }

    $latestVersion = $properties.version
    if ([string]::IsNullOrWhiteSpace($latestVersion) -and $properties.metadata) {
        $latestVersion = $properties.metadata.version
    }
    $latestMajor = Get-MajorVersionNumber -Version $latestVersion
    $status.latestVersion = $latestVersion
    $status.latestMajor = $latestMajor

    # Resolve the version the assignment actually runs: the definition files win, then whatever
    # Azure stamped on the deployed assignment.
    $assignedVersion = $null
    $assignedVersionFrom = "none"
    if (-not [string]::IsNullOrWhiteSpace($DefinitionVersion)) {
        $assignedVersion = $DefinitionVersion
        $assignedVersionFrom = "assignmentFile"
    }
    elseif (-not [string]::IsNullOrWhiteSpace($DeployedDefinitionVersion)) {
        $assignedVersion = $DeployedDefinitionVersion
        $assignedVersionFrom = "deployed"
    }
    $status.assignedVersion = $assignedVersion
    $status.assignedVersionFrom = $assignedVersionFrom

    if ($null -eq $latestMajor) {
        # The built-in publishes no usable version, so no comparison is possible
        return $status
    }

    $assignedMajor = Get-MajorVersionNumber -Version $assignedVersion
    $status.assignedMajor = $assignedMajor

    if ($null -eq $assignedMajor) {
        # No pinned version, or a wildcard major: Azure applies the latest major version
        $status.status = "tracksLatest"
        return $status
    }

    if ($latestMajor -gt $assignedMajor) {
        $status.status = "updateAvailable"
        $status.updateAvailable = $true
    }
    else {
        $status.status = "current"
    }
    return $status
}

function Get-MajorVersionNumber {
    <#
    .SYNOPSIS
    Returns the major version number of a version string or wildcard, or $null when it cannot be determined.
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [string] $Version
    )

    if ([string]::IsNullOrWhiteSpace($Version)) {
        return $null
    }

    # Strip a pre-release suffix such as '-preview' before reading the numeric core
    $core = $Version
    $dashIndex = $core.IndexOf("-")
    if ($dashIndex -ge 0) {
        $core = $core.Substring(0, $dashIndex)
    }

    $major = $core.Split(".")[0]
    $majorNumber = 0
    if (-not [int]::TryParse($major, [ref] $majorNumber)) {
        # A wildcard major, for example '*.*.*', always follows the latest version
        return $null
    }
    return $majorNumber
}