Common/Get-BaselineTrend.ps1

function Get-BaselineTrend {
    <#
    .SYNOPSIS
        Enumerates saved baselines for a tenant and aggregates per-status counts per snapshot.
    .DESCRIPTION
        Scans the Baselines directory for folders matching the tenant's identity
        suffixes, reads each manifest.json for timestamp + version metadata, and
        counts the Status field across every security-config JSON file in the
        baseline. Returns a chronologically sorted array suitable for trend
        visualisation in the report.

        C1 #780: searches BOTH the legacy '_<TenantId>' folder shape AND the new
        '_<TenantGuid>' shape so a tenant carrying baselines from both pre- and
        post-v2.9.0 runs sees its full history on the trend chart. Folder names
        are unique (timestamp-based), so the union doesn't double-count.
    .PARAMETER BaselinesRoot
        Path to the Baselines directory (typically <OutputFolder>/Baselines).
    .PARAMETER TenantId
        Tenant identifier (typically DefaultDomain). Matches legacy baselines
        saved as '<Label>_<TenantId>'.
    .PARAMETER TenantGuid
        Optional canonical tenant GUID. When supplied, also matches v2.9.0+
        baselines saved as '<Label>_<TenantGuid>'.
    .PARAMETER MaxSnapshots
        Maximum number of most-recent snapshots to return. Defaults to 10 — enough
        context for a visible trend without cluttering the chart. Older snapshots
        are dropped.
    .OUTPUTS
        [PSCustomObject[]] One entry per baseline, sorted chronologically:
          Label, SavedAt, Version, Pass, Warn, Fail, Review, Info, Skipped, Total
    .EXAMPLE
        $trend = Get-BaselineTrend -BaselinesRoot '.\M365-Assessment\Baselines' `
                                    -TenantId 'contoso.com' `
                                    -TenantGuid '11111111-2222-3333-4444-555555555555'
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$BaselinesRoot,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter()]
        [string]$TenantGuid = '',

        [Parameter()]
        [ValidateRange(1, 100)]
        [int]$MaxSnapshots = 10
    )

    if (-not (Test-Path -Path $BaselinesRoot -PathType Container)) {
        Write-Verbose "Trend: baselines root '$BaselinesRoot' does not exist"
        return @()
    }

    # C1 #780: union of legacy domain suffix + new GUID suffix. Filter against
    # both so post-v2.9.0 baselines (GUID-keyed) and pre-v2.9.0 baselines
    # (TenantId-keyed) both feed the trend chart.
    $safeTenant = $TenantId -replace '[^\w\.\-]', '_'
    # NB: must use ::new() not `New-Object -TypeName ...` here -- the comma in
    # the generic Dictionary type name is parsed as the array operator by
    # PowerShell when bound to -TypeName, producing 'Cannot convert Object[] to
    # System.String required by parameter TypeName' at runtime. ::new() parses
    # the [...] unambiguously as a type literal. Bug surfaced in v2.9.0 when a
    # real tenant ran with -AutoBaseline; HTML report generation died inside
    # the catch in Invoke-M365Assessment.ps1 and silently dropped the report.
    $matchedDirs = [System.Collections.Generic.Dictionary[string, System.IO.DirectoryInfo]]::new()
    foreach ($d in (Get-ChildItem -Path $BaselinesRoot -Directory -Filter "*_${safeTenant}" -ErrorAction SilentlyContinue)) {
        if (-not $matchedDirs.ContainsKey($d.FullName)) { $matchedDirs[$d.FullName] = $d }
    }
    if ($TenantGuid) {
        $safeGuid = $TenantGuid -replace '[^\w\-]', ''
        foreach ($d in (Get-ChildItem -Path $BaselinesRoot -Directory -Filter "*_${safeGuid}" -ErrorAction SilentlyContinue)) {
            if (-not $matchedDirs.ContainsKey($d.FullName)) { $matchedDirs[$d.FullName] = $d }
        }
    }
    $baselineDirs = @($matchedDirs.Values)

    $snapshots = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($dir in $baselineDirs) {
        try {
            $manifestPath = Join-Path -Path $dir.FullName -ChildPath 'manifest.json'
            if (-not (Test-Path -Path $manifestPath)) {
                Write-Verbose "Trend: skipped '$($dir.Name)' — no manifest.json"
                continue
            }
            $manifest = Get-Content -Path $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json

            $counts = @{ pass = 0; warn = 0; fail = 0; review = 0; info = 0; skipped = 0; total = 0 }
            $jsonFiles = Get-ChildItem -Path $dir.FullName -Filter '*.json' -ErrorAction SilentlyContinue |
                Where-Object { $_.Name -ne 'manifest.json' }

            foreach ($jf in $jsonFiles) {
                $rows = Get-Content -Path $jf.FullName -Raw -ErrorAction Stop | ConvertFrom-Json
                foreach ($row in @($rows)) {
                    $counts.total++
                    switch ($row.Status) {
                        'Pass'    { $counts.pass++ }
                        'Warning' { $counts.warn++ }
                        'Fail'    { $counts.fail++ }
                        'Review'  { $counts.review++ }
                        'Info'    { $counts.info++ }
                        'Skipped' { $counts.skipped++ }
                    }
                }
            }

            $snapshots.Add([PSCustomObject]@{
                Label   = $manifest.Label
                SavedAt = $manifest.SavedAt
                Version = $manifest.AssessmentVersion
                Pass    = $counts.pass
                Warn    = $counts.warn
                Fail    = $counts.fail
                Review  = $counts.review
                Info    = $counts.info
                Skipped = $counts.skipped
                Total   = $counts.total
            })
        }
        catch {
            Write-Verbose "Trend: skipped baseline '$($dir.Name)': $_"
        }
    }

    @($snapshots | Sort-Object SavedAt | Select-Object -Last $MaxSnapshots)
}