Public/Get-TaxonomySnapshot.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Get-TaxonomySnapshot {
    <#
    .SYNOPSIS
        Fetches the taxonomy + conflict file set from the ai-triad-data repo, stamped with the source commit SHA.
    .DESCRIPTION
        Consolidates the multi-file fetch pattern hand-rolled in
        `.github/workflows/container.yml` (t/1493). Downloads 11 files
        (9 taxonomy + 2 conflict) from raw.githubusercontent.com, queries
        the source repo for its latest commit SHA, writes a
        snapshot-meta.json manifest, and validates that the six required
        files are present and non-empty.
 
        Used by the container build to bake a fallback taxonomy snapshot
        into the image; safe for any other consumer that needs a
        point-in-time copy of the data repo.
    .PARAMETER OutputPath
        Directory to write the snapshot into. Layout mirrors the source
        repo — `<OutputPath>/taxonomy/Origin/*.json` and
        `<OutputPath>/conflicts/*.json` — plus `snapshot-meta.json` at
        the root.
    .PARAMETER Repo
        Source repo in `owner/repo` form. Default: 'jpsnover/ai-triad-data'.
    .PARAMETER Branch
        Source branch. Default: 'main'.
    .PARAMETER TimeoutSec
        Per-file HTTP timeout in seconds. Default: 30.
    .EXAMPLE
        Get-TaxonomySnapshot -OutputPath ./taxonomy-snapshot
    .EXAMPLE
        Get-TaxonomySnapshot -OutputPath ./snap -Branch feature-x
    .LINK
        Show-AITriadHelp
    .LINK
        Get-TaxEditorBlob
    .LINK
        Get-TaxEditorDataCommit
    .LINK
        Restore-TaxEditorBlob
    .LINK
        Restore-TaxEditorKnownGood
    .LINK
        Set-TaxEditorKnownGood
    .LINK
        Sync-TaxEditorData
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [Alias('Path')]
        [string]$OutputPath,

        [Parameter()]
        [string]$Repo = 'jpsnover/ai-triad-data',

        [Parameter()]
        [string]$Branch = 'main',

        [Parameter()]
        [ValidateRange(1, 300)]
        [int]$TimeoutSec = 30
    )

    Set-StrictMode -Version Latest

    # 9 taxonomy files under taxonomy/Origin/ + 2 conflict files under conflicts/ = 11 total.
    # Required-set matches container.yml: the 5 POV files + edges + policy registry.
    $TaxonomyFiles = @(
        'accelerationist.json'
        'safetyist.json'
        'skeptic.json'
        'situations.json'
        'edges.json'
        'policy_actions.json'
        'embeddings.json'
        'lineage_categories.json'
        'aggregated-cruxes.json'
    )
    $ConflictFiles = @(
        '_conflict-index.json'
        '_conflict-clusters.json'
    )
    $RequiredTaxonomy = @(
        'accelerationist.json'
        'safetyist.json'
        'skeptic.json'
        'situations.json'
        'edges.json'
        'policy_actions.json'
    )

    $SnapDir      = Join-Path $OutputPath 'taxonomy' | Join-Path -ChildPath 'Origin'
    $ConflictDir  = Join-Path $OutputPath 'conflicts'
    $null = New-Item -ItemType Directory -Path $SnapDir     -Force -ErrorAction Stop
    $null = New-Item -ItemType Directory -Path $ConflictDir -Force -ErrorAction Stop

    $RawBase = "https://raw.githubusercontent.com/$Repo/$Branch"

    $Files = [System.Collections.Generic.List[object]]::new()

    foreach ($f in $TaxonomyFiles) {
        $Url  = "$RawBase/taxonomy/Origin/$f"
        $Dest = Join-Path $SnapDir $f
        $Files.Add((Get-SnapshotFile -Url $Url -Destination $Dest -Category 'taxonomy' -TimeoutSec $TimeoutSec))
    }
    foreach ($f in $ConflictFiles) {
        $Url  = "$RawBase/conflicts/$f"
        $Dest = Join-Path $ConflictDir $f
        $Files.Add((Get-SnapshotFile -Url $Url -Destination $Dest -Category 'conflict' -TimeoutSec $TimeoutSec))
    }

    # ── Resolve source-repo commit SHA ────────────────────────────────────
    $Commit = 'unknown'
    try {
        $CommitInfo = Invoke-GitHubApi -Endpoint "/repos/$Repo/commits/$Branch" `
            -Method 'GET' -CallerName 'Get-TaxonomySnapshot'
        if ($CommitInfo -and $CommitInfo.PSObject.Properties['sha']) {
            $Commit = [string]$CommitInfo.sha
        }
    } catch {
        Write-Warning "Could not resolve commit SHA for $Repo@$Branch — snapshot-meta.commit will be 'unknown' ($($_.Exception.Message))"
    }

    # ── Validate required set ────────────────────────────────────────────
    $Missing = [System.Collections.Generic.List[string]]::new()
    foreach ($f in $RequiredTaxonomy) {
        $Dest = Join-Path $SnapDir $f
        if (-not (Test-Path $Dest) -or (Get-Item $Dest).Length -eq 0) {
            $Missing.Add($f)
        }
    }
    $Valid = ($Missing.Count -eq 0)

    # ── Stamp manifest ───────────────────────────────────────────────────
    $Generated = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
    $Meta = [ordered]@{
        generated = $Generated
        commit    = $Commit
        repo      = $Repo
        branch    = $Branch
        valid     = $Valid
    }
    if (-not $Valid) { $Meta['missing'] = @($Missing) }
    $MetaPath = Join-Path $OutputPath 'snapshot-meta.json'
    $Meta | ConvertTo-Json -Depth 4 | Set-Content -Path $MetaPath -Encoding utf8NoBOM

    $Result = [TaxonomySnapshotResult]::new()
    $Result.OutputPath        = (Resolve-Path $OutputPath).Path
    $Result.Repo              = $Repo
    $Result.Branch            = $Branch
    $Result.Commit            = $Commit
    $Result.Generated         = $Generated
    $Result.Files             = @($Files)
    $Result.Valid             = $Valid
    $Result.MissingRequired   = @($Missing)
    $Result.SnapshotMetaPath  = $MetaPath
    $Result
}

function Get-SnapshotFile {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Url,

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

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

        [Parameter(Mandatory)]
        [int]$TimeoutSec
    )

    Set-StrictMode -Version Latest

    $Entry = [ordered]@{
        Url       = $Url
        Path      = $Destination
        Category  = $Category
        Ok        = $false
        SizeBytes = 0
        Error     = $null
    }

    try {
        $Response = Invoke-WebRequest -Uri $Url -OutFile $Destination `
            -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
        $null = $Response
        if (Test-Path $Destination) {
            $Entry.SizeBytes = (Get-Item $Destination).Length
            $Entry.Ok        = ($Entry.SizeBytes -gt 0)
        }
    } catch {
        $Entry.Error = $_.Exception.Message
        Write-Warning "Failed to fetch $Url — $($_.Exception.Message)"
    }
    [PSCustomObject]$Entry
}