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'
        $payload = [ordered]@{
            tool         = 'TenantLens'
            version      = $script:TLVersion
            generatedUtc = [DateTime]::UtcNow.ToString('o')
            snapshot     = [string]$snapshot._Path
            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
}