src/Export-ScoutRawInventoryDump.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Write every row the collection pass returned to a raw JSON artifact.

.DESCRIPTION
    AB#6764. Scout collects nearly everything and displays about 40% of it. The Resource Graph
    pass is unfiltered -- it pulls every resource type in the estate -- and the collector
    manifests then decide what reaches a worksheet. Everything else was discarded without being
    written anywhere, so a resource type no manifest claims left no trace that it had ever been
    seen.

    That is a display gap, not a collection gap, and dumping the raw rows eliminates it outright
    instead of reporting on it. Building a collector for every undisplayed type is a different
    and much larger job; this makes the data available today.

    Two properties matter and both are enforced by where this is called from:

      - It runs BEFORE the processing phase, so nothing has been filtered by a manifest yet.
      - It writes to the RUN folder, not `ReportCache`, so `Clear-AZSCCacheFolder` -- which
        Invoke-AzureScout runs unconditionally at the end of every run -- does not delete it.

.PARAMETER ExtractionData
    The object returned by `Start-AZSCExtractionOrchestration`.

.PARAMETER DefaultPath
    The run folder.

.PARAMETER FileName
    Artifact name. Defaults to `raw-inventory.json`.

.OUTPUTS
    [string] the path written, or $null if nothing was written.

.NOTES
    Tracks ADO AB#6764 (Feature AB#6743, Epic AB#6731). Format documented in docs/output.md.
#>

function Export-ScoutRawInventoryDump {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowNull()]
        $ExtractionData,

        [Parameter(Mandatory)]
        [string] $DefaultPath,

        [string] $FileName = 'raw-inventory.json'
    )

    if ($null -eq $ExtractionData) {
        Write-Verbose 'Export-ScoutRawInventoryDump: nothing to dump — the extraction returned $null.'
        return $null
    }

    # Read element-wise. $ExtractionData's shape varies with -Scope and the -Skip* switches
    # (an EntraOnly run has no Resources at all), and under StrictMode dotting an absent
    # property throws rather than returning $null.
    function Get-Set {
        param($Object, [string] $Name)
        if ($Object.PSObject.Properties[$Name]) { @($Object.$Name) } else { @() }
    }

    $sets = [ordered]@{}
    foreach ($name in 'Resources', 'ResourceContainers', 'Advisories', 'Security', 'Retirements',
                      'EntraResources', 'Quotas', 'PolicyAssign', 'PolicyDef', 'PolicySetDef') {
        $sets[$name] = Get-Set -Object $ExtractionData -Name $name
    }

    # The type census is the point of the artifact, not decoration: it is the shortest answer to
    # "what did the estate contain that no worksheet showed?", and it is what makes the file
    # worth opening before scrolling through the rows.
    $typeCounts = @{}
    foreach ($row in @($sets['Resources'])) {
        if ($null -eq $row) { continue }
        $t = if ($row.PSObject.Properties['type'] -and $row.type) { [string] $row.type }
             elseif ($row.PSObject.Properties['TYPE'] -and $row.TYPE) { [string] $row.TYPE }
             else { '(untyped)' }
        if ($typeCounts.ContainsKey($t)) { $typeCounts[$t]++ } else { $typeCounts[$t] = 1 }
    }

    $path = Join-Path $DefaultPath $FileName
    if (-not $PSCmdlet.ShouldProcess($path, 'Write raw inventory dump')) { return $null }

    try {
        $payload = [ordered]@{
            Schema      = 'azure-scout/raw-inventory/v1'
            GeneratedAt = (Get-Date).ToString('o')
            Counts      = [ordered]@{}
            ResourceTypes = @(
                $typeCounts.GetEnumerator() | Sort-Object Name |
                    ForEach-Object { [PSCustomObject]@{ Type = $_.Key; Rows = $_.Value } }
            )
        }
        foreach ($name in $sets.Keys) { $payload.Counts[$name] = @($sets[$name]).Count }
        foreach ($name in $sets.Keys) { $payload[$name] = @($sets[$name]) }

        # Depth 100 matches what the assessment layer uses for collect.json. The `properties`
        # bag on a raw ARG row is arbitrarily nested and a shallower depth would silently
        # truncate it to a type name, which is the exact silent loss this artifact exists to end.
        [PSCustomObject] $payload | ConvertTo-Json -Depth 100 | Out-File -LiteralPath $path -Encoding utf8

        Write-Verbose "Export-ScoutRawInventoryDump: wrote $((@($sets['Resources'])).Count) resource rows across $($typeCounts.Count) types to '$path'."
        return $path
    }
    catch {
        # A dump failure must never cost the caller their report.
        Write-Warning "[AzureScout] Could not write the raw inventory dump to '$path': $($_.Exception.Message)"
        return $null
    }
}