Private/Invoke-DependencyCheck.ps1

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

# Shared dependency checking engine used by Install-Dependencies and Test-Dependencies.
# Dot-sourced by AITriad.psm1 — do NOT export.

function Invoke-DependencyCheck {
    <#
    .SYNOPSIS
        Core dependency checking engine. Returns a structured results object.
    .DESCRIPTION
        Checks all project dependencies, runs smoke tests, and returns a hashtable
        of results. Caller controls whether to fix (install) or just report.
    .PARAMETER Mode
        'install' — check + offer to fix. 'test' — check + version freshness, no fixing.
    .PARAMETER Fix
        When Mode=install, actually attempt to install missing deps.
    .PARAMETER Quiet
        Suppress passing checks in output.
    .PARAMETER SkipNode
        Skip Node.js checks.
    .PARAMETER SkipPython
        Skip Python checks.
    .PARAMETER RepoRoot
        Repository root path.
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('install', 'test')]
        [string]$Mode = 'test',

        [switch]$Fix,
        [switch]$Quiet,
        [switch]$SkipNode,
        [switch]$SkipPython,
        [string]$RepoRoot = $script:RepoRoot
    )

    Set-StrictMode -Version Latest

    # ─── Counters ─────────────────────────────────────────────────────────────
    $Ctx = @{
        Passed  = 0
        Warned  = 0
        Failed  = 0
        Fixed   = 0
        Outdated = 0
        Results = [System.Collections.Generic.List[PSObject]]::new()
    }

    # ─── Output helpers ───────────────────────────────────────────────────────
    # These need to close over $Quiet and $Ctx, so define as scriptblocks
    function DPass  { param([string]$M) $Ctx.Passed++;  if (-not $Quiet) { Write-Host " `u{2713} $M" -ForegroundColor Green }; $Ctx.Results.Add([PSCustomObject]@{ Status='pass'; Message=$M }) }
    function DWarn  { param([string]$M) $Ctx.Warned++;  Write-Host " `u{26A0} $M" -ForegroundColor Yellow; $Ctx.Results.Add([PSCustomObject]@{ Status='warn'; Message=$M }) }
    function DFail  { param([string]$M) $Ctx.Failed++;  Write-Host " `u{2717} $M" -ForegroundColor Red;    $Ctx.Results.Add([PSCustomObject]@{ Status='fail'; Message=$M }) }
    function DSkip  { param([string]$M) if (-not $Quiet) { Write-Host " `u{2192} $M" -ForegroundColor DarkGray } }
    function DFix   { param([string]$M) Write-Host " `u{1F527} $M" -ForegroundColor Cyan }
    function DStale { param([string]$M) $Ctx.Outdated++; Write-Host " `u{2B06} $M" -ForegroundColor Yellow; $Ctx.Results.Add([PSCustomObject]@{ Status='outdated'; Message=$M }) }
    function DSection { param([string]$M) Write-Host "`n $M" -ForegroundColor White; Write-Host " $('─' * 50)" -ForegroundColor DarkGray }

    $IsTestMode    = $Mode -eq 'test'
    $IsInstallMode = $Mode -eq 'install'

    # ─── Platform ─────────────────────────────────────────────────────────────
    $OnMac   = $IsMacOS -or ($PSVersionTable.OS -match 'Darwin')
    $OnLinux = $IsLinux -or ($PSVersionTable.OS -match 'Linux')
    $Platform = if ($OnMac) { 'macOS' } elseif ($OnLinux) { 'Linux' } else { 'Windows' }

    function Get-PkgMgr {
        if ($OnMac)    { if (Get-Command brew    -ErrorAction SilentlyContinue) { return 'brew' } }
        if ($OnLinux)  { if (Get-Command apt-get  -ErrorAction SilentlyContinue) { return 'apt' }
                         if (Get-Command dnf      -ErrorAction SilentlyContinue) { return 'dnf' }
                         if (Get-Command yum      -ErrorAction SilentlyContinue) { return 'yum' } }
        if ($IsWindows){ if (Get-Command winget   -ErrorAction SilentlyContinue) { return 'winget' }
                         if (Get-Command choco    -ErrorAction SilentlyContinue) { return 'choco' }
                         if (Get-Command scoop    -ErrorAction SilentlyContinue) { return 'scoop' } }
        return $null
    }

    function Install-Pkg {
        param([string]$Name, [hashtable]$PackageNames)
        if (-not $Fix) { return $false }
        $PM = Get-PkgMgr
        if (-not $PM) { DFail "Cannot auto-install '$Name' — no package manager found"; return $false }
        $PkgName = $PackageNames[$PM]
        if (-not $PkgName) { DFail "No package mapping for '$Name' on $PM"; return $false }
        DFix "Installing $Name via $PM ($PkgName)..."
        try {
            switch ($PM) {
                'brew'   { & brew install $PkgName 2>&1 | Out-Null }
                'apt'    { & sudo apt-get install -y $PkgName 2>&1 | Out-Null }
                'dnf'    { & sudo dnf install -y $PkgName 2>&1 | Out-Null }
                'yum'    { & sudo yum install -y $PkgName 2>&1 | Out-Null }
                'winget' { & winget install --id $PkgName --accept-package-agreements --accept-source-agreements 2>&1 | Out-Null }
                'choco'  { & choco install $PkgName -y 2>&1 | Out-Null }
                'scoop'  { & scoop install $PkgName 2>&1 | Out-Null }
            }
            if ($LASTEXITCODE -eq 0) { $Ctx.Fixed++; DPass "$Name installed"; return $true }
            else { DFail "$Name installation failed (exit code $LASTEXITCODE)"; return $false }
        }
        catch { DFail "$Name installation failed: $_"; return $false }
    }

    # ═══════════════════════════════════════════════════════════════════════════
    $TitleVerb = if ($IsTestMode) { 'Dependency Test' } else { 'Dependency Check' }
    Write-Host "`n$('═' * 60)" -ForegroundColor Cyan
    Write-Host " AI Triad Research — $TitleVerb" -ForegroundColor White
    Write-Host " Platform: $Platform | Mode: $Mode$(if ($Fix) { ' (fix)' })" -ForegroundColor Gray
    Write-Host "$('═' * 60)" -ForegroundColor Cyan

    # ── 1. POWERSHELL ─────────────────────────────────────────────────────────
    DSection 'POWERSHELL (required)'

    $PsVer = $PSVersionTable.PSVersion
    if ($PsVer.Major -ge 7) {
        DPass "PowerShell $PsVer"
    }
    else {
        DFail "PowerShell 7+ required (found $PsVer). Install from https://aka.ms/powershell"
    }

    # Module check — we're already running inside the module, just verify commands exist
    $CmdCount = (Get-Command -Module AITriad -ErrorAction SilentlyContinue).Count
    if ($CmdCount -gt 0) { DPass "AITriad module loaded ($CmdCount commands)" }
    else { DWarn 'AITriad module not loaded in current session' }

    # ── 2. GIT ────────────────────────────────────────────────────────────────
    DSection 'GIT (required)'

    if (Get-Command git -ErrorAction SilentlyContinue) {
        try {
            $GitVer = (git --version 2>&1) -replace 'git version ', ''
            $GitRoot = git -C $RepoRoot rev-parse --show-toplevel 2>&1
            if ($LASTEXITCODE -eq 0) {
                DPass "git $GitVer"
            }
            else {
                DWarn "git $GitVer installed but repo check failed"
            }
        }
        catch { DWarn "git found but smoke test failed: $_" }
    }
    else {
        DFail 'git not found'
        if ($IsInstallMode) {
            Install-Pkg -Name 'git' -PackageNames @{
                brew = 'git'; apt = 'git'; dnf = 'git'; winget = 'Git.Git'; choco = 'git'
            }
        }
    }

    # ── 3. AI API KEYS ────────────────────────────────────────────────────────
    DSection 'AI API KEYS (at least one required)'

    $HasAnyKey = $false

    if ($env:GEMINI_API_KEY) {
        try {
            $Uri = "https://generativelanguage.googleapis.com/v1beta/models?key=$($env:GEMINI_API_KEY)"
            $R = Invoke-RestMethod -Uri $Uri -Method Get -TimeoutSec 10 -ErrorAction Stop
            $ModelCount = @($R.models).Count
            DPass "GEMINI_API_KEY valid ($ModelCount models available)"
            $HasAnyKey = $true
        }
        catch {
            $SC = $_.Exception.Response.StatusCode.value__
            if ($SC -eq 400 -or $SC -eq 403) { DFail "GEMINI_API_KEY invalid (HTTP $SC)" }
            else { DWarn "GEMINI_API_KEY set but API unreachable"; $HasAnyKey = $true }
        }
    }
    else { DWarn 'GEMINI_API_KEY not set (primary backend)' }

    if ($env:ANTHROPIC_API_KEY) {
        try {
            $Hdrs = @{ 'x-api-key' = $env:ANTHROPIC_API_KEY; 'anthropic-version' = '2023-06-01'; 'content-type' = 'application/json' }
            $Body = @{ model = 'claude-3-5-haiku-20241022'; max_tokens = 10; messages = @(@{ role = 'user'; content = 'Say OK' }) } | ConvertTo-Json -Depth 5
            $null = Invoke-RestMethod -Uri 'https://api.anthropic.com/v1/messages' -Method Post -Headers $Hdrs -Body $Body -TimeoutSec 15 -ErrorAction Stop
            DPass 'ANTHROPIC_API_KEY valid'
            $HasAnyKey = $true
        }
        catch {
            $SC = $_.Exception.Response.StatusCode.value__
            if ($SC -eq 401) { DFail 'ANTHROPIC_API_KEY invalid (HTTP 401)' }
            else { DWarn "ANTHROPIC_API_KEY set but smoke test failed"; $HasAnyKey = $true }
        }
    }
    else { DSkip 'ANTHROPIC_API_KEY not set (optional)' }

    if ($env:GROQ_API_KEY) {
        try {
            $Hdrs = @{ 'Authorization' = "Bearer $($env:GROQ_API_KEY)"; 'Content-Type' = 'application/json' }
            $null = Invoke-RestMethod -Uri 'https://api.groq.com/openai/v1/models' -Method Get -Headers $Hdrs -TimeoutSec 10 -ErrorAction Stop
            DPass 'GROQ_API_KEY valid'
            $HasAnyKey = $true
        }
        catch {
            $SC = $_.Exception.Response.StatusCode.value__
            if ($SC -eq 401) { DFail 'GROQ_API_KEY invalid (HTTP 401)' }
            else { DWarn "GROQ_API_KEY set but smoke test failed"; $HasAnyKey = $true }
        }
    }
    else { DSkip 'GROQ_API_KEY not set (optional)' }

    if (-not $HasAnyKey -and $env:AI_API_KEY) {
        DWarn 'AI_API_KEY (fallback) set but cannot verify which backend it targets'
        $HasAnyKey = $true
    }
    if (-not $HasAnyKey) {
        DFail 'No AI API key configured. Set GEMINI_API_KEY or run Register-AIBackend.'
    }

    # ── 4. NODE.JS & NPM ─────────────────────────────────────────────────────
    if (-not $SkipNode) {
        DSection 'NODE.JS & NPM (required for desktop apps)'

        $HasNode = $false
        if (Get-Command node -ErrorAction SilentlyContinue) {
            try {
                $NodeVer = (node --version 2>&1).Trim()
                $Major = [int]($NodeVer -replace '^v', '' -split '\.' | Select-Object -First 1)
                if ($Major -ge 20) {
                    $NodeResult = node -e "console.log(JSON.stringify({ok:true,version:process.version}))" 2>&1
                    $NodeJson = $NodeResult | ConvertFrom-Json
                    if ($NodeJson.ok) {
                        DPass "Node.js $($NodeJson.version) (>= v20 required)"
                        $HasNode = $true
                    }
                    else { DWarn "Node.js $NodeVer — smoke test failed" }
                }
                else { DFail "Node.js $NodeVer too old (v20+ required)" }
            }
            catch { DWarn "Node.js found but smoke test failed: $_" }
        }
        else {
            DFail 'Node.js not found'
            if ($IsInstallMode) {
                Install-Pkg -Name 'node' -PackageNames @{
                    brew = 'node@22'; apt = 'nodejs'; dnf = 'nodejs'
                    winget = 'OpenJS.NodeJS.LTS'; choco = 'nodejs-lts'; scoop = 'nodejs-lts'
                }
            }
        }

        if (Get-Command npm -ErrorAction SilentlyContinue) {
            $NpmVer = (npm --version 2>&1).Trim()
            DPass "npm $NpmVer"
        }
        else { DFail 'npm not found' }

        # Electron apps
        $ElectronApps = @('taxonomy-editor', 'poviewer', 'summary-viewer', 'edge-viewer')
        foreach ($App in $ElectronApps) {
            $AppDir   = Join-Path $RepoRoot $App
            $PkgJson  = Join-Path $AppDir 'package.json'
            $NodeMods = Join-Path $AppDir 'node_modules'

            if (-not (Test-Path $PkgJson)) { DWarn "$App — package.json not found"; continue }

            if (Test-Path $NodeMods) {
                $ModCount = (Get-ChildItem -Path $NodeMods -Directory | Measure-Object).Count
                DPass "$App — node_modules present ($ModCount packages)"

                # Test mode: check for outdated packages
                if ($IsTestMode -and $HasNode) {
                    try {
                        Push-Location $AppDir
                        $OutdatedRaw = npm outdated --json 2>$null
                        Pop-Location
                        if ($OutdatedRaw) {
                            $Outdated = $OutdatedRaw | ConvertFrom-Json
                            $OutdatedCount = $Outdated.PSObject.Properties.Count
                            if ($OutdatedCount -gt 0) {
                                DStale "$App — $OutdatedCount outdated package(s) (run 'npm update' in $App/ to update)"
                                # Show top 3
                                $Shown = 0
                                foreach ($Prop in $Outdated.PSObject.Properties) {
                                    if ($Shown -ge 3) { break }
                                    $Pkg = $Prop.Value
                                    $CurVer = if ($Pkg.PSObject.Properties['current']) { $Pkg.current } else { '?' }
                                    $WantVer = if ($Pkg.PSObject.Properties['wanted']) { $Pkg.wanted } else { '?' }
                                    Write-Host " $($Prop.Name): $CurVer -> $WantVer" -ForegroundColor DarkGray
                                    $Shown++
                                }
                                if ($OutdatedCount -gt 3) {
                                    Write-Host " ... and $($OutdatedCount - 3) more" -ForegroundColor DarkGray
                                }
                            }
                        }
                    }
                    catch { }  # npm outdated can fail gracefully
                }
            }
            else {
                DWarn "$App — node_modules missing"
                if ($IsInstallMode -and $Fix -and $HasNode) {
                    DFix "Running npm install in $App..."
                    Push-Location $AppDir
                    try {
                        npm install 2>&1 | Out-Null
                        if ($LASTEXITCODE -eq 0) { $Ctx.Fixed++; DPass "$App — npm install succeeded" }
                        else { DFail "$App — npm install failed (exit code $LASTEXITCODE)" }
                    }
                    catch { DFail "$App — npm install failed: $_" }
                    finally { Pop-Location }
                }
                else { DSkip "Run 'npm install' in $App/" }
            }
        }
    }
    else {
        DSection 'NODE.JS & NPM (skipped)'
        DSkip 'Skipped via -SkipNode'
    }

    # ── 5. DOCUMENT CONVERSION ────────────────────────────────────────────────
    DSection 'DOCUMENT CONVERSION (recommended)'

    if (Get-Command pandoc -ErrorAction SilentlyContinue) {
        try {
            $PandocVer = (pandoc --version 2>&1 | Select-Object -First 1) -replace 'pandoc ', ''
            $TestResult = '<p>Hello</p>' | pandoc -f html -t markdown_strict --wrap=none 2>&1
            if ($TestResult -match 'Hello') { DPass "pandoc $PandocVer (smoke test passed)" }
            else { DWarn "pandoc $PandocVer — conversion smoke test failed" }
        }
        catch { DWarn "pandoc found but smoke test failed: $_" }
    }
    else {
        DWarn 'pandoc not found — HTML/DOCX conversion will use basic fallback'
        if ($IsInstallMode) {
            Install-Pkg -Name 'pandoc' -PackageNames @{
                brew = 'pandoc'; apt = 'pandoc'; dnf = 'pandoc'
                winget = 'JohnMacFarlane.Pandoc'; choco = 'pandoc'; scoop = 'pandoc'
            }
        }
    }

    if (Get-Command pdftotext -ErrorAction SilentlyContinue) {
        try {
            $PdfVer = (pdftotext -v 2>&1 | Select-Object -First 1)
            DPass "pdftotext available ($PdfVer)"
        }
        catch { DPass 'pdftotext available' }
    }
    elseif (Get-Command mutool -ErrorAction SilentlyContinue) {
        DPass 'mutool available (fallback PDF extractor)'
    }
    else {
        DWarn 'Neither pdftotext nor mutool found — PDF extraction will be limited'
        if ($IsInstallMode) {
            Install-Pkg -Name 'poppler' -PackageNames @{
                brew = 'poppler'; apt = 'poppler-utils'; dnf = 'poppler-utils'; yum = 'poppler-utils'
            }
        }
    }

    # ── 6. PYTHON & EMBEDDINGS ────────────────────────────────────────────────
    if (-not $SkipPython) {
        DSection 'PYTHON & EMBEDDINGS (optional)'

        $PythonCmd = $null
        foreach ($Cmd in @('python3', 'python')) {
            if (Get-Command $Cmd -ErrorAction SilentlyContinue) {
                try {
                    $PyVer = & $Cmd --version 2>&1
                    $PyMajor = [int](("$PyVer" -replace 'Python ', '') -split '\.' | Select-Object -First 1)
                    if ($PyMajor -ge 3) {
                        $PyTest = & $Cmd -c "import json; print(json.dumps({'ok': True}))" 2>&1
                        $PyJson = $PyTest | ConvertFrom-Json
                        if ($PyJson.ok) { $PythonCmd = $Cmd; DPass "$Cmd — $PyVer"; break }
                    }
                }
                catch { }
            }
        }

        if (-not $PythonCmd) {
            DWarn 'Python 3 not found — Update-TaxEmbeddings will not work'
            if ($IsInstallMode) {
                Install-Pkg -Name 'python3' -PackageNames @{
                    brew = 'python@3'; apt = 'python3'; dnf = 'python3'
                    winget = 'Python.Python.3.12'; choco = 'python3'; scoop = 'python'
                }
            }
        }
        else {
            $ReqFile = Join-Path $RepoRoot 'scripts' 'requirements.txt'
            if (Test-Path $ReqFile) {
                try {
                    $ImportTest = & $PythonCmd -c "import sentence_transformers; print(sentence_transformers.__version__)" 2>$null
                    if ($LASTEXITCODE -eq 0 -and $ImportTest) {
                        DPass "sentence-transformers $("$ImportTest".Trim())"

                        # Test mode: check if pip packages are outdated
                        if ($IsTestMode) {
                            try {
                                $PipOutdated = & $PythonCmd -m pip list --outdated --format=json 2>$null
                                if ($LASTEXITCODE -eq 0 -and $PipOutdated) {
                                    $OutdatedPkgs = $PipOutdated | ConvertFrom-Json
                                    # Filter to packages in our requirements.txt
                                    $ReqNames = @(Get-Content $ReqFile | Where-Object { $_ -match '^\w' } | ForEach-Object { ($_ -split '[>=<]')[0].Trim().ToLower() })
                                    $Relevant = @($OutdatedPkgs | Where-Object { $_.name.ToLower() -in $ReqNames })
                                    if ($Relevant.Count -gt 0) {
                                        DStale "$($Relevant.Count) Python package(s) outdated (run '$PythonCmd -m pip install -U -r scripts/requirements.txt' to update)"
                                        foreach ($Pkg in $Relevant | Select-Object -First 3) {
                                            Write-Host " $($Pkg.name): $($Pkg.version) -> $($Pkg.latest_version)" -ForegroundColor DarkGray
                                        }
                                        if ($Relevant.Count -gt 3) {
                                            Write-Host " ... and $($Relevant.Count - 3) more" -ForegroundColor DarkGray
                                        }
                                    }
                                }
                            }
                            catch { }  # pip outdated can fail gracefully
                        }
                    }
                    else {
                        DWarn 'sentence-transformers not installed'
                        if ($IsInstallMode -and $Fix) {
                            DFix 'Installing Python requirements...'
                            & $PythonCmd -m pip install -r $ReqFile 2>&1 | Out-Null
                            if ($LASTEXITCODE -eq 0) { $Ctx.Fixed++; DPass 'Python requirements installed' }
                            else { DFail 'pip install failed' }
                        }
                        else { DSkip "Run '$PythonCmd -m pip install -r scripts/requirements.txt'" }
                    }
                }
                catch { DWarn "Could not check Python packages: $_" }
            }

            $EmbFile = Get-TaxonomyDir 'embeddings.json'
            if (Test-Path $EmbFile) {
                try {
                    $EmbData = Get-Content -Raw -Path $EmbFile | ConvertFrom-Json -Depth 3
                    $EmbCount = if ($EmbData.PSObject.Properties['node_count']) { $EmbData.node_count } else { '?' }
                    DPass "embeddings.json present ($EmbCount node embeddings)"

                    # Test mode: check if embeddings are stale (more taxonomy nodes than embeddings)
                    if ($IsTestMode -and $EmbCount -ne '?') {
                        $TotalTaxNodes = 0
                        foreach ($PovKey in @('accelerationist', 'safetyist', 'skeptic', 'cross-cutting')) {
                            $E = $script:TaxonomyData[$PovKey]
                            if ($E) { $TotalTaxNodes += @($E.nodes).Count }
                        }
                        if ($TotalTaxNodes -gt [int]$EmbCount) {
                            DStale "embeddings.json has $EmbCount embeddings but taxonomy has $TotalTaxNodes nodes — run Update-TaxEmbeddings"
                        }
                    }
                }
                catch {
                    $EmbSize = [Math]::Round((Get-Item $EmbFile).Length / 1MB, 1)
                    DPass "embeddings.json present (${EmbSize}MB)"
                }
            }
            else { DSkip 'embeddings.json not yet generated — run Update-TaxEmbeddings' }
        }
    }
    else {
        DSection 'PYTHON & EMBEDDINGS (skipped)'
        DSkip 'Skipped via -SkipPython'
    }

    # ── 7. DOCKER & NEO4J ────────────────────────────────────────────────────
    DSection 'DOCKER & NEO4J (optional — graph database)'

    if (Get-Command docker -ErrorAction SilentlyContinue) {
        try {
            $DockerVer = ((docker --version 2>&1) -replace 'Docker version ', '' -replace ',.*', '').Trim()
            if ($DockerVer) {
                $DockerPing = docker info 2>&1
                if ($LASTEXITCODE -eq 0) {
                    DPass "Docker $DockerVer (daemon running)"
                    $Neo4jContainer = docker ps -a --filter 'name=ai-triad-neo4j' --format '{{.Status}}' 2>&1
                    if ($Neo4jContainer) {
                        if ($Neo4jContainer -match 'Up') { DPass "Neo4j container running" }
                        else { DWarn "Neo4j container exists but stopped — docker start ai-triad-neo4j" }
                    }
                    else { DSkip 'Neo4j container not created — run Install-GraphDatabase' }
                }
                else { DWarn "Docker $DockerVer installed but daemon not running" }
            }
            else { DWarn 'Docker found but version check failed' }
        }
        catch { DWarn "Docker smoke test failed: $_" }
    }
    else {
        DSkip 'Docker not installed (only needed for Neo4j graph database)'
    }

    # ── 8. DATA INTEGRITY ────────────────────────────────────────────────────
    DSection 'DATA INTEGRITY'

    $TaxDir    = Get-TaxonomyDir
    $TaxFiles  = @('accelerationist.json', 'safetyist.json', 'skeptic.json', 'cross-cutting.json')
    $TotalNodes = 0
    foreach ($TF in $TaxFiles) {
        $TFPath = Join-Path $TaxDir $TF
        if (Test-Path $TFPath) {
            try { $TData = Get-Content -Raw -Path $TFPath | ConvertFrom-Json; $TotalNodes += @($TData.nodes).Count }
            catch { DFail "$TF — failed to parse JSON" }
        }
        else { DFail "$TF — not found in taxonomy/Origin/" }
    }
    if ($TotalNodes -gt 0) { DPass "Taxonomy valid ($TotalNodes nodes across $($TaxFiles.Count) POVs)" }

    $EdgesPath = Join-Path $TaxDir 'edges.json'
    if (Test-Path $EdgesPath) {
        try { $EData = Get-Content -Raw -Path $EdgesPath | ConvertFrom-Json; DPass "edges.json valid ($(@($EData.edges).Count) edges)" }
        catch { DFail 'edges.json — failed to parse' }
    }
    else { DSkip 'edges.json not yet generated' }

    foreach ($DirInfo in @(
        @{ Name = 'summaries'; Filter = '*.json'; Type = 'File' }
        @{ Name = 'sources';   Filter = $null;     Type = 'Directory' }
        @{ Name = 'conflicts'; Filter = '*.json'; Type = 'File' }
    )) {
        $DirPath = Join-Path $RepoRoot $DirInfo.Name
        if (Test-Path $DirPath) {
            $Params = @{ Path = $DirPath }
            if ($DirInfo.Filter) { $Params['Filter'] = $DirInfo.Filter; $Params['File'] = $true }
            else { $Params['Directory'] = $true }
            $Count = (Get-ChildItem @Params).Count
            DPass "$($DirInfo.Name)/ — $Count items"
        }
        else { DSkip "$($DirInfo.Name)/ not found" }
    }

    # ── SUMMARY ──────────────────────────────────────────────────────────────
    Write-Host "`n$('═' * 60)" -ForegroundColor Cyan
    Write-Host " RESULTS" -ForegroundColor White
    Write-Host "$('═' * 60)" -ForegroundColor Cyan

    $TotalChecks = $Ctx.Passed + $Ctx.Warned + $Ctx.Failed
    Write-Host " Passed : $($Ctx.Passed)" -ForegroundColor Green
    Write-Host " Warnings: $($Ctx.Warned)" -ForegroundColor Yellow
    Write-Host " Failed : $($Ctx.Failed)" -ForegroundColor $(if ($Ctx.Failed -gt 0) { 'Red' } else { 'Green' })
    if ($Ctx.Outdated -gt 0) {
        Write-Host " Outdated: $($Ctx.Outdated)" -ForegroundColor Yellow
    }
    if ($Ctx.Fixed -gt 0) {
        Write-Host " Fixed : $($Ctx.Fixed)" -ForegroundColor Cyan
    }
    Write-Host " Total : $TotalChecks checks" -ForegroundColor Gray

    if ($Ctx.Failed -gt 0) {
        Write-Host "`n Some required dependencies are missing." -ForegroundColor Red
        if ($IsInstallMode -and -not $Fix) {
            Write-Host " Re-run with -Fix to attempt automatic installation." -ForegroundColor Yellow
        }
    }
    elseif ($Ctx.Outdated -gt 0) {
        Write-Host "`n All dependencies present but $($Ctx.Outdated) item(s) are outdated." -ForegroundColor Yellow
        Write-Host " NOT updating automatically — review the items above and update manually." -ForegroundColor Yellow
    }
    elseif ($Ctx.Warned -gt 0) {
        Write-Host "`n All required dependencies present. Some optional features may be limited." -ForegroundColor Yellow
    }
    else {
        Write-Host "`n All dependencies satisfied and up to date." -ForegroundColor Green
    }

    Write-Host "$('═' * 60)`n" -ForegroundColor Cyan

    return $Ctx
}