Public/Test-AzureUtilsTagCompliance.ps1

function Test-AzureUtilsTagCompliance {
    <#
        .SYNOPSIS
            Finds resources that are missing one or more required tags.

        .DESCRIPTION
            Uses Azure Resource Graph to check every resource in scope against a set
            of required tag keys and returns one object per violating resource, with
            a 'MissingTags' list and a human-readable 'Reason'. This is the linter
            that closes the loop on Export-AzureUtilsTagInventory /
            Set-AzureUtilsTagInventory: define the tags your governance requires,
            then find who is out of compliance.

            A tag counts as present only when it exists AND is non-empty.

        .PARAMETER RequiredTag
            One or more tag keys every resource must carry (case-insensitive keys,
            matched exactly by Azure). Required.

        .PARAMETER SubscriptionId
            One or more subscription IDs. Default: every enabled subscription.

        .PARAMETER ManagementGroupId
            One or more management groups to scan (requires Az.ResourceGraph).

        .EXAMPLE
            Test-AzureUtilsTagCompliance -RequiredTag costCenter, environment

        .EXAMPLE
            Test-AzureUtilsTagCompliance -RequiredTag owner -ManagementGroupId 'PLAT' |
                Export-Csv .\tag-violations.csv -NoTypeInformation

        .OUTPUTS
            AzureUtils.TagComplianceViolation
    #>

    [CmdletBinding(DefaultParameterSetName = 'Subscriptions')]
    [OutputType('AzureUtils.TagComplianceViolation')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string[]] $RequiredTag,

        [Parameter(ParameterSetName = 'Subscriptions')]
        [string[]] $SubscriptionId,

        [Parameter(ParameterSetName = 'ManagementGroup', Mandatory)]
        [string[]] $ManagementGroupId
    )

    $null = Assert-AzureUtilsContext

    $resolved = Resolve-AzureUtilsScope -SubscriptionId $SubscriptionId -ManagementGroupId $ManagementGroupId
    if ($resolved.Type -eq 'Subscription' -and -not $resolved.HasSubscriptions) {
        Write-Warning 'No accessible subscriptions in scope.'
        return
    }

    $query   = New-AzureUtilsTagComplianceQuery -RequiredTag $RequiredTag
    $nameMap = $resolved.NameMap
    $scope   = $resolved.Scope
    Write-Verbose "Tag-compliance query:`n$query"

    Invoke-AzureUtilsGraphQuery -Query $query @scope | ForEach-Object {
        $subId = [string]$_.subscriptionId
        [pscustomobject]@{
            PSTypeName       = 'AzureUtils.TagComplianceViolation'
            Name             = $_.name
            ResourceType     = $_.type
            ResourceGroup    = $_.resourceGroup
            Location         = $_.location
            SubscriptionName = if ($nameMap.ContainsKey($subId)) { $nameMap[$subId] } else { $subId }
            SubscriptionId   = $subId
            MissingTags      = @($_.missingTags)
            Reason           = $_.reason
            Tags             = ConvertTo-AzureUtilsHashtable -InputObject $_.tags
            ResourceId       = $_.id
        }
    }
}