Public/Get-GraphNode.ps1

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

function Get-GraphNode {
    <#
    .SYNOPSIS
        Retrieves a taxonomy node with its edges and graph attributes.
    .DESCRIPTION
        Loads a node by ID and returns it enriched with all inbound and outbound
        edges from edges.json. Optionally traverses the graph to a given depth.
    .PARAMETER Id
        The node ID to retrieve (e.g., "acc-goals-001").
    .PARAMETER Depth
        How many hops of edges to include. Default: 1 (direct neighbors only).
    .PARAMETER EdgeType
        Filter to only show edges of this type (e.g., TENSION_WITH, ASSUMES).
    .PARAMETER Status
        Filter edges by approval status. Default: all statuses.
        Valid values: proposed, approved, rejected.
    .PARAMETER RepoRoot
        Path to the repository root. Defaults to the module-resolved repo root.
    .EXAMPLE
        Get-GraphNode -Id "saf-goals-001"
    .EXAMPLE
        Get-GraphNode -Id "acc-goals-001" -Depth 2 -EdgeType TENSION_WITH
    .EXAMPLE
        Get-GraphNode -Id "cc-001" -Status approved
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Id,

        [ValidateRange(0, 5)]
        [int]$Depth = 1,

        [string]$EdgeType = '',

        [ValidateSet('proposed', 'approved', 'rejected', '')]
        [string]$Status = '',

        [string]$RepoRoot = $script:RepoRoot
    )

    Set-StrictMode -Version Latest

    $TaxDir = Get-TaxonomyDir

    # Load all nodes
    $AllNodes = @{}
    $NodePovMap = @{}
    foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic', 'cross-cutting')) {
        $FilePath = Join-Path $TaxDir "$PovKey.json"
        if (-not (Test-Path $FilePath)) { continue }
        try {
            $FileData = Get-Content -Raw -Path $FilePath | ConvertFrom-Json
        }
        catch {
            Write-Warn "Failed to load $PovKey.json — $($_.Exception.Message)"
            continue
        }
        foreach ($Node in $FileData.nodes) {
            $AllNodes[$Node.id] = $Node
            $NodePovMap[$Node.id] = $PovKey
        }
    }

    if (-not $AllNodes.ContainsKey($Id)) {
        Write-Fail "Node not found: $Id"
        return
    }

    # Load edges
    $EdgesPath = Join-Path $TaxDir 'edges.json'
    $Edges = @()
    if (Test-Path $EdgesPath) {
        try {
            $EdgesData = Get-Content -Raw -Path $EdgesPath | ConvertFrom-Json
            $Edges = @($EdgesData.edges)
        }
        catch {
            Write-Warn "Failed to load edges.json — $($_.Exception.Message)"
        }
    }

    # BFS traversal
    $Visited = [System.Collections.Generic.HashSet[string]]::new()
    $Queue = [System.Collections.Generic.Queue[PSObject]]::new()
    $Queue.Enqueue([PSCustomObject]@{ Id = $Id; CurrentDepth = 0 })
    [void]$Visited.Add($Id)

    $ResultNodes = [System.Collections.Generic.List[PSObject]]::new()
    $ResultEdges = [System.Collections.Generic.List[PSObject]]::new()

    while ($Queue.Count -gt 0) {
        $Current = $Queue.Dequeue()
        $CurrentId = $Current.Id
        $CurrentDepth = $Current.CurrentDepth

        # Add node to results
        $Node = $AllNodes[$CurrentId]
        $NodeResult = [PSCustomObject]@{
            id               = $Node.id
            pov              = $NodePovMap[$CurrentId]
            label            = $Node.label
            description      = $Node.description
            graph_attributes = if ($Node.PSObject.Properties['graph_attributes']) { $Node.graph_attributes } else { $null }
            depth            = $CurrentDepth
        }
        $ResultNodes.Add($NodeResult)

        if ($CurrentDepth -ge $Depth) { continue }

        # Find connected edges
        foreach ($Edge in $Edges) {
            $IsOutbound = $Edge.source -eq $CurrentId
            $IsInbound  = $Edge.target -eq $CurrentId
            $IsBidir    = $Edge.PSObject.Properties['bidirectional'] -and $Edge.bidirectional

            if (-not ($IsOutbound -or ($IsInbound -and $IsBidir))) { continue }

            # Apply filters
            if ($EdgeType -and $Edge.type -ne $EdgeType) { continue }
            if ($Status -and $Edge.status -ne $Status) { continue }

            $NeighborId = if ($IsOutbound) { $Edge.target } else { $Edge.source }

            $ResultEdges.Add($Edge)

            if (-not $Visited.Contains($NeighborId) -and $AllNodes.ContainsKey($NeighborId)) {
                [void]$Visited.Add($NeighborId)
                $Queue.Enqueue([PSCustomObject]@{ Id = $NeighborId; CurrentDepth = $CurrentDepth + 1 })
            }
        }
    }

    [PSCustomObject]@{
        root_node = $Id
        nodes     = $ResultNodes
        edges     = $ResultEdges
        stats     = [PSCustomObject]@{
            node_count = $ResultNodes.Count
            edge_count = $ResultEdges.Count
            max_depth  = $Depth
        }
    }
}