Private/ConvertTo-AACTagUsage.ps1

function ConvertTo-AACTagUsage {
    <#
    .SYNOPSIS
        Builds Get-AACTagUsage's list (AAC.TagUsage) from the Resource Graph
        rows of Get-AACTagUsageQuery: each tag name, where it is used, its
        values, and what is inconsistent about it.
    .DESCRIPTION
        No Azure calls. -Read is Invoke-AACGraphBatch's result;
        -RequiredTag the tag names that should be everywhere (each gets a
        row, used or not, with its coverage).
 
        Each tag name as spelled (AAC.TagUsage): its uses on resources,
        resource groups and subscriptions, coverage (the share of resources
        that carry it), distinct values and a sample, and findings:
          Medium the same name in other casing ('Environment',
                  'environment') - Azure treats tag names case-
                  insensitively, so these collide in cost reports and
                  policies; a required tag on too few resources
          Low near-duplicates (the same name once spaces, dashes and
                  underscores are ignored: 'cost-center', 'CostCenter');
                  the same value in different casing ('Prod', 'prod')
          Info a tag used once (a typo?)
        Returns @{ Tags; Totals; Notices; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [hashtable] $Read,

        [string[]] $RequiredTag = @(),

        [ValidateRange(1, 100)]
        [int] $CoverageTarget = 90
    )

    $get = ${function:Get-AACMember}
    $text = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v) { '' } else { [string]$v } }
    $number = { param($Object, [string] $Path) $v = & $get $Object $Path; if ($null -eq $v -or '' -eq $v) { 0 } else { [int64]$v } }
    $list = { param($Item) @(if ($Item -is [System.Collections.IEnumerable] -and $Item -isnot [string] -and $Item -isnot [System.Collections.IDictionary]) { $Item } elseif ($null -ne $Item) { , $Item }) | Where-Object { $null -ne $_ } }
    $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) }
    $notices = [System.Collections.Generic.List[string]]::new()
    foreach ($key in @($Read.Errors.Keys | Sort-Object)) { $notices.Add("The $(@{ resourceTags = 'resource tags'; groupTags = 'resource group tags'; subscriptionTags = 'subscription tags'; totals = 'totals' }[$key]) couldn't be read: $($Read.Errors[$key] -replace '\s+', ' ')") }

    $totals = @{ resources = 0; resourceGroups = 0; subscriptions = 0 }
    $tagged = @{ resources = 0; resourceGroups = 0; subscriptions = 0 }
    foreach ($row in (& $rowsOf 'totals')) { $k = & $text $row 'kind'; if ($totals.Contains($k)) { $totals[$k] = & $number $row 'total'; $tagged[$k] = & $number $row 'tagged' } }

    # Tag names as spelled, with their use in each place.
    $ordinal = [System.StringComparer]::Ordinal
    $byName = [System.Collections.Generic.Dictionary[string, hashtable]]::new($ordinal)
    foreach ($pair in @(@('resourceTags', 'Resources'), @('groupTags', 'ResourceGroups'), @('subscriptionTags', 'Subscriptions'))) {
        foreach ($row in (& $rowsOf $pair[0])) {
            $name = & $text $row 'name'
            if (-not $name) { continue }
            if (-not $byName.ContainsKey($name)) { $byName[$name] = @{ Resources = 0; ResourceGroups = 0; Subscriptions = 0; Values = [System.Collections.Generic.HashSet[string]]::new($ordinal); ValueCount = 0 } }
            $entry = $byName[$name]
            $entry[$pair[1]] = & $number $row 'uses'
            $entry.ValueCount = [Math]::Max($entry.ValueCount, (& $number $row 'valueCount'))
            foreach ($v in @(& $list (& $get $row 'values'))) { [void]$entry.Values.Add([string]$v) }
        }
    }
    foreach ($required in @($RequiredTag | Where-Object { $_ })) {
        if (-not @($byName.Keys | Where-Object { $_ -eq $required }).Count) {
            $byName[$required] = @{ Resources = 0; ResourceGroups = 0; Subscriptions = 0; Values = [System.Collections.Generic.HashSet[string]]::new($ordinal); ValueCount = 0 }
        }
    }

    $normal = { param([string] $Name) ($Name.ToLowerInvariant() -replace '[\s_\-\.]', '') }
    $rank = (Get-AACSeverityRank).Rank
    $items = [System.Collections.Generic.List[object]]::new()
    foreach ($name in @($byName.Keys)) {
        $e = $byName[$name]
        $findings = [System.Collections.Generic.List[object]]::new()
        $sameIgnoringCase = @($byName.Keys | Where-Object { $_ -cne $name -and $_.ToLowerInvariant() -eq $name.ToLowerInvariant() })
        $near = @($byName.Keys | Where-Object { $_.ToLowerInvariant() -ne $name.ToLowerInvariant() -and (& $normal $_) -eq (& $normal $name) })
        if ($sameIgnoringCase.Count) { $findings.Add(@{ S = 'Medium'; T = "Also spelled $(($sameIgnoringCase | ForEach-Object { "'$_'" }) -join ', ') - tag names are case-insensitive in Azure, so these collide in cost reports and policies; settle on one." }) }
        if ($near.Count) { $findings.Add(@{ S = 'Low'; T = "Near-duplicate of $(($near | ForEach-Object { "'$_'" }) -join ', ') - pick one name." }) }
        $valueGroups = @($e.Values | Group-Object { $_.ToLowerInvariant() } | Where-Object { @($_.Group | Select-Object -Unique).Count -gt 1 })
        if ($valueGroups.Count) { $findings.Add(@{ S = 'Low'; T = "Values differ only in casing: $((@($valueGroups | Select-Object -First 3 | ForEach-Object { ($_.Group | Select-Object -Unique) -join '/' })) -join '; ') - reports and policies see them as different." }) }
        $coverage = if ($totals.resources) { [Math]::Round(100.0 * $e.Resources / $totals.resources, 1) } else { 0 }
        $isRequired = @($RequiredTag | Where-Object { $_ -eq $name }).Count -gt 0
        if ($isRequired -and $coverage -lt $CoverageTarget) { $findings.Add(@{ S = 'Medium'; T = "Required, but on only $coverage% of resources ($($e.Resources) of $($totals.resources)) - assign a policy to require or inherit it." }) }
        $uses = $e.Resources + $e.ResourceGroups + $e.Subscriptions
        if ($uses -eq 1 -and -not $isRequired) { $findings.Add(@{ S = 'Info'; T = 'Used once - a typo, or a tag nobody else needs?' }) }
        $worst = @($findings | Sort-Object { $rank[$_.S] } | Select-Object -First 1)
        $sample = @($e.Values | Sort-Object | Select-Object -First 8)
        $item = [pscustomobject][ordered]@{
            Severity       = $(if ($worst.Count) { $worst[0].S } else { 'Info' })
            Tag            = $name
            Required       = $isRequired
            Resources      = $e.Resources
            ResourceGroups = $e.ResourceGroups
            Subscriptions  = $e.Subscriptions
            Coverage       = $coverage
            Values         = [Math]::Max($e.ValueCount, $e.Values.Count)
            SampleValues   = ($sample -join ', ') + $(if ($e.Values.Count -gt 8) { ', ...' } else { '' })
            Findings       = (@($findings | ForEach-Object { $_.T }) -join ' ')
        }
        $item.PSObject.TypeNames.Insert(0, 'AAC.TagUsage')
        $items.Add($item)
    }
    $sorted = @($items | Sort-Object -Property @{ Expression = { if ($_.Findings) { $rank[$_.Severity] } else { 9 } } }, @{ Expression = { $_.Resources + $_.ResourceGroups + $_.Subscriptions }; Descending = $true }, Tag)
    @{
        Tags    = $sorted
        Totals  = @{ Resources = $totals.resources; TaggedResources = $tagged.resources; ResourceGroups = $totals.resourceGroups; TaggedResourceGroups = $tagged.resourceGroups; Subscriptions = $totals.subscriptions; TaggedSubscriptions = $tagged.subscriptions }
        Notices = @($notices)
        Stats   = @{
            Tags        = $sorted.Count
            Findings    = @($sorted | Where-Object Findings).Count
            CaseClashes = @($sorted | Where-Object { $_.Findings -like 'Also spelled*' }).Count
            Coverage    = $(if ($totals.resources) { [Math]::Round(100.0 * $tagged.resources / $totals.resources, 1) } else { 0 })
        }
    }
}