Public/Update-PolicyRegistry.ps1

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

function Update-PolicyRegistry {
    <#
    .SYNOPSIS
        Rebuild and validate the policy action registry from taxonomy files.
    .DESCRIPTION
        Scans all POV and cross-cutting taxonomy files, collects every
        policy_actions entry, and rebuilds policy_actions.json.

        Detects and reports:
        - Orphaned policies (in registry but not referenced by any node)
        - Unregistered policies (referenced by nodes but missing from registry)
        - Stale member_count or source_povs fields

        Use -Fix to automatically repair issues (remove orphans, assign IDs
        to unregistered entries, update counts).
    .PARAMETER Fix
        Automatically fix detected issues.
    .PARAMETER PassThru
        Return a summary object.
    .EXAMPLE
        Update-PolicyRegistry
    .EXAMPLE
        Update-PolicyRegistry -Fix
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [switch]$Fix,
        [switch]$PassThru
    )

    Set-StrictMode -Version Latest

    $TaxDir = Get-TaxonomyDir
    $RegistryPath = Join-Path $TaxDir 'policy_actions.json'

    # ── Load existing registry ──
    $Registry = $null
    $ExistingPolicies = @{}
    if (Test-Path $RegistryPath) {
        $Registry = Get-Content -Raw -Path $RegistryPath | ConvertFrom-Json
        foreach ($Pol in $Registry.policies) {
            $ExistingPolicies[$Pol.id] = $Pol
        }
        Write-OK "Loaded registry: $($Registry.policies.Count) policies"
    }
    else {
        Write-Info 'No existing registry found — will create new one'
    }

    # ── Scan all taxonomy files ──
    $PovFiles = @('accelerationist', 'safetyist', 'skeptic', 'cross-cutting')
    $ReferencedIds = @{}  # policy_id -> list of { NodeId, POV, Action, Framing }
    $Unregistered  = [System.Collections.Generic.List[object]]::new()

    foreach ($PovKey in $PovFiles) {
        $FilePath = Join-Path $TaxDir "$PovKey.json"
        if (-not (Test-Path $FilePath)) { continue }
        $FileData = Get-Content -Raw -Path $FilePath | ConvertFrom-Json

        foreach ($Node in $FileData.nodes) {
            if (-not $Node.PSObject.Properties['graph_attributes'] -or $null -eq $Node.graph_attributes) { continue }
            if (-not $Node.graph_attributes.PSObject.Properties['policy_actions']) { continue }

            foreach ($PA in $Node.graph_attributes.policy_actions) {
                $Pid = if ($PA.PSObject.Properties['policy_id']) { $PA.policy_id } else { $null }

                if (-not $Pid) {
                    $Unregistered.Add([PSCustomObject]@{
                        NodeId  = $Node.id
                        POV     = $PovKey
                        Action  = $PA.action
                        Framing = $PA.framing
                    })
                    continue
                }

                if (-not $ReferencedIds.ContainsKey($Pid)) {
                    $ReferencedIds[$Pid] = [System.Collections.Generic.List[object]]::new()
                }
                $ReferencedIds[$Pid].Add([PSCustomObject]@{
                    NodeId = $Node.id
                    POV    = $PovKey
                })
            }
        }
    }

    # ── Detect issues ──
    $AllRegisteredIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($Key in $ExistingPolicies.Keys) { [void]$AllRegisteredIds.Add($Key) }

    $AllReferencedIds = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($Key in $ReferencedIds.Keys) { [void]$AllReferencedIds.Add($Key) }

    $Orphans = @($AllRegisteredIds | Where-Object { -not $AllReferencedIds.Contains($_) })
    $Missing = @($AllReferencedIds | Where-Object { -not $AllRegisteredIds.Contains($_) })

    Write-Host ''
    Write-Host '=== Policy Registry Validation ===' -ForegroundColor Cyan
    Write-Host " Referenced by nodes: $($ReferencedIds.Count) unique policy IDs" -ForegroundColor White
    Write-Host " In registry: $($ExistingPolicies.Count) policies" -ForegroundColor White
    Write-Host " Orphaned: $($Orphans.Count)" -ForegroundColor $(if ($Orphans.Count -gt 0) { 'Yellow' } else { 'Green' })
    Write-Host " Missing from registry: $($Missing.Count)" -ForegroundColor $(if ($Missing.Count -gt 0) { 'Yellow' } else { 'Green' })
    Write-Host " Unregistered (no ID): $($Unregistered.Count)" -ForegroundColor $(if ($Unregistered.Count -gt 0) { 'Yellow' } else { 'Green' })

    if ($Orphans.Count -gt 0) {
        Write-Warn 'Orphaned policies (in registry but not referenced):'
        foreach ($Oid in $Orphans | Select-Object -First 10) {
            $Pol = $ExistingPolicies[$Oid]
            Write-Host " $Oid`: $($Pol.action.Substring(0, [Math]::Min(80, $Pol.action.Length)))" -ForegroundColor Yellow
        }
        if ($Orphans.Count -gt 10) { Write-Host " ... +$($Orphans.Count - 10) more" -ForegroundColor Yellow }
    }

    if ($Unregistered.Count -gt 0) {
        Write-Warn 'Policy actions without policy_id:'
        foreach ($U in $Unregistered | Select-Object -First 5) {
            Write-Host " $($U.NodeId) [$($U.POV)]: $($U.Action.Substring(0, [Math]::Min(80, $U.Action.Length)))" -ForegroundColor Yellow
        }
        if ($Unregistered.Count -gt 5) { Write-Host " ... +$($Unregistered.Count - 5) more" -ForegroundColor Yellow }
    }

    # ── Fix if requested ──
    if ($Fix -and ($Orphans.Count -gt 0 -or $Unregistered.Count -gt 0 -or $true)) {
        Write-Step 'Fixing registry...'

        # Remove orphans
        if ($Orphans.Count -gt 0 -and $PSCmdlet.ShouldProcess("$($Orphans.Count) orphaned policies", 'Remove')) {
            foreach ($Oid in $Orphans) {
                $ExistingPolicies.Remove($Oid)
            }
            Write-OK "Removed $($Orphans.Count) orphaned policies"
        }

        # Assign IDs to unregistered actions
        if ($Unregistered.Count -gt 0 -and $PSCmdlet.ShouldProcess("$($Unregistered.Count) unregistered actions", 'Assign IDs')) {
            $MaxId = 0
            foreach ($Key in $ExistingPolicies.Keys) {
                if ($Key -match 'pol-(\d+)') {
                    $Num = [int]$Matches[1]
                    if ($Num -gt $MaxId) { $MaxId = $Num }
                }
            }

            foreach ($U in $Unregistered) {
                $MaxId++
                $NewId = 'pol-{0:D3}' -f $MaxId
                $ExistingPolicies[$NewId] = [PSCustomObject]@{
                    id           = $NewId
                    action       = $U.Action
                    source_povs  = @($U.POV)
                    member_count = 1
                }

                # Update the node in the taxonomy file
                $FilePath = Join-Path $TaxDir "$($U.POV).json"
                $FileData = Get-Content -Raw -Path $FilePath | ConvertFrom-Json
                foreach ($Node in $FileData.nodes) {
                    if ($Node.id -ne $U.NodeId) { continue }
                    foreach ($PA in $Node.graph_attributes.policy_actions) {
                        if ($PA.action -eq $U.Action -and (-not $PA.PSObject.Properties['policy_id'] -or $null -eq $PA.policy_id)) {
                            $PA | Add-Member -NotePropertyName 'policy_id' -NotePropertyValue $NewId -Force
                            break
                        }
                    }
                }
                $FileData | ConvertTo-Json -Depth 20 | Set-Content -Path $FilePath -Encoding UTF8
                Write-Info " Assigned $NewId to $($U.NodeId)`: $($U.Action.Substring(0, [Math]::Min(50, $U.Action.Length)))"
            }
        }

        # Rebuild member_count and source_povs
        # Re-scan after fixes
        $FinalRefs = @{}
        foreach ($PovKey in $PovFiles) {
            $FilePath = Join-Path $TaxDir "$PovKey.json"
            if (-not (Test-Path $FilePath)) { continue }
            $FileData = Get-Content -Raw -Path $FilePath | ConvertFrom-Json
            foreach ($Node in $FileData.nodes) {
                if (-not $Node.PSObject.Properties['graph_attributes'] -or $null -eq $Node.graph_attributes) { continue }
                if (-not $Node.graph_attributes.PSObject.Properties['policy_actions']) { continue }
                foreach ($PA in $Node.graph_attributes.policy_actions) {
                    $Pid = if ($PA.PSObject.Properties['policy_id']) { $PA.policy_id } else { $null }
                    if (-not $Pid) { continue }
                    if (-not $FinalRefs.ContainsKey($Pid)) {
                        $FinalRefs[$Pid] = @{ Count = 0; POVs = [System.Collections.Generic.HashSet[string]]::new() }
                    }
                    $FinalRefs[$Pid].Count++
                    [void]$FinalRefs[$Pid].POVs.Add($PovKey)
                }
            }
        }

        foreach ($Pid in $ExistingPolicies.Keys) {
            $Pol = $ExistingPolicies[$Pid]
            if ($FinalRefs.ContainsKey($Pid)) {
                $Pol.member_count = $FinalRefs[$Pid].Count
                $Pol.source_povs  = @($FinalRefs[$Pid].POVs | Sort-Object)
            }
        }

        # Write registry
        $NewRegistry = [PSCustomObject]@{
            _schema_version = '1.0.0'
            _doc            = 'Canonical policy action registry. Each policy has a unique ID. Nodes reference policies by ID with POV-specific framing.'
            policy_count    = $ExistingPolicies.Count
            policies        = @($ExistingPolicies.Values | Sort-Object id)
        }

        if ($PSCmdlet.ShouldProcess($RegistryPath, 'Write rebuilt policy registry')) {
            $NewRegistry | ConvertTo-Json -Depth 10 | Set-Content -Path $RegistryPath -Encoding UTF8
            Write-OK "Registry saved: $($ExistingPolicies.Count) policies"
        }
    }

    if ($PassThru) {
        [PSCustomObject]@{
            TotalPolicies = $ExistingPolicies.Count
            Referenced    = $ReferencedIds.Count
            Orphans       = $Orphans.Count
            Unregistered  = $Unregistered.Count
            Missing       = $Missing.Count
        }
    }
}