Public/Test-TenantLens.ps1

function Test-TenantLens {
    <#
    .SYNOPSIS
        Evaluates the rule catalog against a snapshot and returns findings.
    .DESCRIPTION
        Every rule produces exactly one finding with status Pass, Fail, Manual,
        Skipped (area not collected) or Error (rule threw). Findings carry
        rationale, remediation and references so they are useful standalone.
    .EXAMPLE
        Test-TenantLens -SnapshotPath .\out\contoso_2026-07-23_1430
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$SnapshotPath,

        # Additional rule catalog folders (e.g. a company baseline). Loading a
        # rule path executes its PSD1 files - only use trusted catalogs.
        [string[]]$RulePath,

        # Evaluate only the catalogs given via -RulePath.
        [switch]$ExcludeDefaultRules,

        # Only evaluate these rule ids.
        [string[]]$RuleId,

        # Only evaluate rules for these areas.
        [string[]]$Area,

        [ValidateSet('Critical', 'High', 'Medium', 'Low', 'Info')]
        [string[]]$Severity,

        # Write findings.json to this folder (defaults to none).
        [string]$OutputPath
    )

    $snapshot = Read-TLSnapshot -Path $SnapshotPath
    $rules = @(Get-TLRuleDefinition -Path $RulePath -IncludeDefault:(-not $ExcludeDefaultRules))

    if ($RuleId) { $rules = @($rules | Where-Object { $RuleId -contains $_.Id }) }
    if ($Area) { $rules = @($rules | Where-Object { $Area -contains $_.Area }) }
    if ($Severity) { $rules = @($rules | Where-Object { $Severity -contains $_.Severity }) }
    if ($rules.Count -eq 0) {
        Write-Warning 'No rules matched the given filters.'
        return
    }

    $validStatuses = @('Pass', 'Fail', 'Manual', 'Skipped', 'Error')
    $findings = foreach ($rule in ($rules | Sort-Object -Property Id)) {
        $status = 'Skipped'
        $evidence = @()

        $areaProperty = $snapshot.PSObject.Properties[$rule.Area]
        if ($null -eq $areaProperty) {
            $evidence = @("Area '$($rule.Area)' was not collected (missing scope or collector skipped).")
        }
        else {
            try {
                $result = & $rule.Test $snapshot
                if ($result -is [string]) {
                    $status = $result
                }
                elseif ($result -is [hashtable]) {
                    $status = [string]$result['Status']
                    $evidence = @($result['Evidence'])
                }
                elseif ($null -ne $result -and $result.PSObject.Properties['Status']) {
                    $status = [string]$result.Status
                    if ($result.PSObject.Properties['Evidence']) { $evidence = @($result.Evidence) }
                }
                else {
                    $status = 'Error'
                    $evidence = @('Rule returned no usable result object.')
                }
                if ($validStatuses -notcontains $status) {
                    $evidence = @("Rule returned invalid status '$status'.") + $evidence
                    $status = 'Error'
                }
            }
            catch {
                $status = 'Error'
                $evidence = @("Rule evaluation threw: $($_.Exception.Message)")
            }
        }

        [pscustomobject]@{
            RuleId       = $rule.Id
            Title        = $rule.Title
            Area         = $rule.Area
            Severity     = $rule.Severity
            Status       = $status
            Evidence     = @($evidence | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ })
            Rationale    = $rule.Rationale
            Remediation  = $rule.Remediation
            References   = @($rule.References)
            SnapshotPath = [string]$snapshot._Path
        }
    }

    if ($OutputPath) {
        $findingsFile = Join-Path -Path $OutputPath -ChildPath 'findings.json'

        $score = $null
        $evaluatedCount = @($findings | Where-Object { @('Pass', 'Fail') -contains $_.Status }).Count
        if ($evaluatedCount -gt 0) {
            $score = [int][Math]::Round(100.0 * (Get-TLScoreWeight -Findings @($findings)))
        }
        $statusCounts = [ordered]@{}
        foreach ($statusName in @('Pass', 'Fail', 'Manual', 'Skipped', 'Error')) {
            $statusCounts[$statusName] = @($findings | Where-Object { $_.Status -eq $statusName }).Count
        }
        $openBySeverity = [ordered]@{}
        foreach ($severityName in @('Critical', 'High', 'Medium', 'Low', 'Info')) {
            $openBySeverity[$severityName] = @($findings | Where-Object { $_.Status -eq 'Fail' -and $_.Severity -eq $severityName }).Count
        }
        $tenantInfo = $null
        if ($snapshot.PSObject.Properties['_Manifest'] -and $snapshot._Manifest -and
            $snapshot._Manifest.PSObject.Properties['tenant']) {
            $tenantInfo = $snapshot._Manifest.tenant
        }
        $skippedAreas = @()
        if ($snapshot.PSObject.Properties['_Areas'] -and $snapshot._Areas) {
            $skippedAreas = @($snapshot._Areas.PSObject.Properties | Where-Object { $_.Value.Skipped } | ForEach-Object { $_.Name })
        }

        $payload = [ordered]@{
            schemaVersion = 2
            tool          = 'TenantLens'
            version       = $script:TLVersion
            generatedUtc  = [DateTime]::UtcNow.ToString('o')
            snapshot      = [string]$snapshot._Path
            tenant        = $tenantInfo
            summary       = [ordered]@{
                score          = $score
                rulesEvaluated = @($findings).Count
                statusCounts   = $statusCounts
                openBySeverity = $openBySeverity
                skippedAreas   = $skippedAreas
            }
            findings      = @($findings | Select-Object -Property * -ExcludeProperty SnapshotPath)
        }
        Write-TLFile -Path $findingsFile -Content (ConvertTo-Json -InputObject $payload -Depth 8)
        Write-Verbose ("Findings written to '{0}'." -f $findingsFile)
    }

    return $findings
}