Public/Get-PovLineage.ps1

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

function Get-PovLineage {
    <#
    .SYNOPSIS
        Traces the ancestry and/or descendants of a taxonomy node.
    .DESCRIPTION
        Given one or more taxonomy node IDs, walks the parent-child hierarchy
        to display the full lineage. By default shows ancestors (root → node).
        Use -Descendants to show the subtree below each node instead, or -Both
        to display the full connected tree.
 
        Output includes depth-indented labels for visual inspection and typed
        objects for pipeline use.
    .PARAMETER Id
        One or more taxonomy node ID patterns (supports wildcards).
    .PARAMETER Label
        One or more wildcard patterns matched against node labels.
    .PARAMETER POV
        Filter to a specific POV before resolving IDs/labels.
    .PARAMETER Descendants
        Show descendants (children, grandchildren, etc.) instead of ancestors.
    .PARAMETER Both
        Show both ancestors and descendants.
    .PARAMETER Depth
        Maximum depth for descendant traversal. Default: 10.
    .PARAMETER Raw
        Suppress the formatted tree display and emit only typed objects.
    .EXAMPLE
        Get-PovLineage acc-desires-001
        # Shows the ancestor chain from root to acc-desires-001.
    .EXAMPLE
        Get-PovLineage 'acc-desires-*' -Descendants
        # Lineage for all accelerationist desire nodes.
    .EXAMPLE
        Get-PovLineage -Label '*governance*'
        # Finds nodes by label, then shows their lineage.
    .EXAMPLE
        Get-PovLineage -POV skeptic -Label '*bias*' -Both
        # Skeptic nodes matching 'bias', showing full trees.
    .EXAMPLE
        Get-PovLineage acc-beliefs-039 -Both
        # Full lineage: ancestors above, descendants below.
    .EXAMPLE
        Get-Tax -POV skeptic -Id 'skp-beliefs-*' | Get-PovLineage -Raw
        # Pipeline: trace ancestors for all skeptic belief nodes.
    #>

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

        [string[]]$Label,

        [ArgumentCompleter({ param($cmd, $param, $word) @('accelerationist','safetyist','skeptic','situations') | Where-Object { $_ -like "$word*" } })]
        [string]$POV = '*',

        [switch]$Descendants,

        [switch]$Both,

        [ValidateRange(1, 50)]
        [int]$Depth = 10,

        [switch]$Raw
    )

    begin {
        Set-StrictMode -Version Latest
        $ErrorActionPreference = 'Stop'
        $CollectedIds = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($i in $Id) {
            if (-not [string]::IsNullOrWhiteSpace($i)) { $CollectedIds.Add($i) }
        }
    }

    end {
        Assert-TaxonomyCacheFresh

        # Build a lookup table: node ID → TaxonomyNode
        $NodeLookup = @{}
        foreach ($Key in $script:TaxonomyData.Keys) {
            if ($Key -notlike $POV.ToLower()) { continue }
            $Entry = $script:TaxonomyData[$Key]
            foreach ($Node in $Entry.nodes) {
                $NodeLookup[$Node.id] = @{ POV = $Key; Node = $Node }
            }
        }
        # Always include all POVs in the full lookup for ancestor/descendant traversal
        $FullLookup = @{}
        foreach ($Key in $script:TaxonomyData.Keys) {
            $Entry = $script:TaxonomyData[$Key]
            foreach ($Node in $Entry.nodes) {
                $FullLookup[$Node.id] = @{ POV = $Key; Node = $Node }
            }
        }

        # Resolve wildcards and labels into concrete node IDs
        $ResolvedIds = [System.Collections.Generic.List[string]]::new()
        $HasId    = $CollectedIds.Count -gt 0
        $HasLabel = ($null -ne $Label) -and ($Label.Length -gt 0)

        if (-not $HasId -and -not $HasLabel) {
            Write-Warning 'Specify -Id or -Label to select nodes.'
            return
        }

        foreach ($Entry in $NodeLookup.Values) {
            $Matched = $false
            if ($HasId) {
                foreach ($Pat in $CollectedIds) {
                    if ($Entry.Node.id -like $Pat) { $Matched = $true; break }
                }
            }
            if (-not $Matched -and $HasLabel) {
                foreach ($Pat in $Label) {
                    if ($Entry.Node.label -like $Pat) { $Matched = $true; break }
                }
            }
            if ($Matched -and -not $ResolvedIds.Contains($Entry.Node.id)) {
                $ResolvedIds.Add($Entry.Node.id)
            }
        }

        if ($ResolvedIds.Count -eq 0) {
            $SearchTerms = @()
            if ($HasId) { $SearchTerms += ($CollectedIds | ForEach-Object { "Id='$_'" }) }
            if ($HasLabel) { $SearchTerms += ($Label | ForEach-Object { "Label='$_'" }) }
            Write-Warning "No nodes matched: $($SearchTerms -join ', ')"
            return
        }

        foreach ($NodeId in $ResolvedIds) {
            $Info = $FullLookup[$NodeId]
            if (-not $Info) { continue }

            $ShowAncestors   = (-not $Descendants) -or $Both
            $ShowDescendants = $Descendants -or $Both

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

            # ── Ancestors: walk up via ParentId ──────────────────────────
            if ($ShowAncestors) {
                $AncestorStack = [System.Collections.Generic.List[PSObject]]::new()
                $Current = $Info
                $Visited = [System.Collections.Generic.HashSet[string]]::new(
                    [System.StringComparer]::OrdinalIgnoreCase)
                $null = $Visited.Add($NodeId)

                while ($Current.Node.PSObject.Properties['parent_id'] -and $Current.Node.parent_id -and $FullLookup.ContainsKey($Current.Node.parent_id)) {
                    $ParentId = $Current.Node.parent_id
                    if (-not $Visited.Add($ParentId)) { break }  # cycle guard
                    $Current = $FullLookup[$ParentId]
                    $AncestorStack.Add([PSCustomObject]@{
                        PSTypeName   = 'TaxonomyNode.Lineage'
                        Id           = $Current.Node.id
                        Label        = $Current.Node.label
                        Category     = if ($Current.Node.PSObject.Properties['category']) { $Current.Node.category } else { $null }
                        POV          = $Current.POV
                        Depth        = 0  # set below
                        Relationship = 'ancestor'
                        TargetId     = $NodeId
                    })
                }

                # Reverse so root is first, assign depths
                $AncestorStack.Reverse()
                for ($i = 0; $i -lt $AncestorStack.Count; $i++) {
                    $AncestorStack[$i].Depth = $i
                    $Results.Add($AncestorStack[$i])
                }

                # Add the target node itself
                $TargetDepth = $AncestorStack.Count
                $Results.Add([PSCustomObject]@{
                    PSTypeName   = 'TaxonomyNode.Lineage'
                    Id           = $Info.Node.id
                    Label        = $Info.Node.label
                    Category     = if ($Info.Node.PSObject.Properties['category']) { $Info.Node.category } else { $null }
                    POV          = $Info.POV
                    Depth        = $TargetDepth
                    Relationship = 'self'
                    TargetId     = $NodeId
                })
            } else {
                # Descendants-only: emit the root node at depth 0
                $TargetDepth = 0
                $Results.Add([PSCustomObject]@{
                    PSTypeName   = 'TaxonomyNode.Lineage'
                    Id           = $Info.Node.id
                    Label        = $Info.Node.label
                    Category     = if ($Info.Node.PSObject.Properties['category']) { $Info.Node.category } else { $null }
                    POV          = $Info.POV
                    Depth        = 0
                    Relationship = 'self'
                    TargetId     = $NodeId
                })
            }

            # ── Descendants: walk down via Children (BFS) ────────────────
            if ($ShowDescendants) {
                $Queue = [System.Collections.Generic.Queue[PSObject]]::new()
                $DescVisited = [System.Collections.Generic.HashSet[string]]::new(
                    [System.StringComparer]::OrdinalIgnoreCase)
                $null = $DescVisited.Add($NodeId)

                $ChildIds = @($Info.Node.children)
                foreach ($cid in $ChildIds) {
                    if ([string]::IsNullOrWhiteSpace($cid)) { continue }
                    $Queue.Enqueue([PSCustomObject]@{ Id = $cid; DepthOffset = 1 })
                }

                while ($Queue.Count -gt 0) {
                    $Item = $Queue.Dequeue()
                    if (-not $DescVisited.Add($Item.Id)) { continue }
                    if ($Item.DepthOffset -gt $Depth) { continue }

                    $ChildInfo = $FullLookup[$Item.Id]
                    if (-not $ChildInfo) { continue }

                    $Results.Add([PSCustomObject]@{
                        PSTypeName   = 'TaxonomyNode.Lineage'
                        Id           = $ChildInfo.Node.id
                        Label        = $ChildInfo.Node.label
                        Category     = if ($ChildInfo.Node.PSObject.Properties['category']) { $ChildInfo.Node.category } else { $null }
                        POV          = $ChildInfo.POV
                        Depth        = $TargetDepth + $Item.DepthOffset
                        Relationship = 'descendant'
                        TargetId     = $NodeId
                    })

                    $GrandChildren = @($ChildInfo.Node.children)
                    foreach ($gcid in $GrandChildren) {
                        if ([string]::IsNullOrWhiteSpace($gcid)) { continue }
                        $Queue.Enqueue([PSCustomObject]@{ Id = $gcid; DepthOffset = $Item.DepthOffset + 1 })
                    }
                }
            }

            # ── Output ───────────────────────────────────────────────────
            if (-not $Raw) {
                Write-Host ''
                Write-Host " Lineage: $NodeId" -ForegroundColor Cyan
                Write-Host " $('─' * 60)" -ForegroundColor DarkGray
                foreach ($R in $Results) {
                    $Indent = ' ' + (' ' * $R.Depth)
                    $Prefix = switch ($R.Relationship) {
                        'self'       { '►' }
                        'ancestor'   { '│' }
                        'descendant' { '├' }
                    }
                    $Color = switch ($R.Relationship) {
                        'self'       { 'Green' }
                        'ancestor'   { 'White' }
                        'descendant' { 'Gray' }
                    }
                    $CatSuffix = if ($R.Category) { " [$($R.Category)]" } else { '' }
                    Write-Host "${Indent}${Prefix} $($R.Id) $($R.Label)${CatSuffix}" -ForegroundColor $Color
                }
                Write-Host ''
            }

            foreach ($R in $Results) { $R }
        }
    }
}