Public/Get-AITSBOM.ps1

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

function Get-AITSBOM {
    <#
    .SYNOPSIS
        Generates a Software Bill of Materials (SBOM) for the AI Triad project.
    .DESCRIPTION
        Enumerates all project dependencies across PowerShell modules, Node.js
        packages, Python packages, system tools, AI models, and schemas.
 
        Enriches entries with license, supplier, description, and integrity hash
        from local lock files and package metadata (no network calls).
 
        With -CheckUpdates, queries package registries for latest versions.
        With -Update, upgrades outdated packages (prompts unless -Force).
    .PARAMETER CheckUpdates
        Query registries for latest available versions.
    .PARAMETER Update
        Update outdated packages. Prompts for confirmation unless -Force.
    .PARAMETER Force
        Skip confirmation prompts when updating.
    .PARAMETER Format
        Output format: Table (default), Json, Csv, CycloneDX, SPDX.
    .PARAMETER RepoRoot
        Repository root path. Defaults to module-resolved root.
    .EXAMPLE
        Get-AITSBOM
    .EXAMPLE
        Get-AITSBOM -CheckUpdates
    .EXAMPLE
        Get-AITSBOM -Format Json | Set-Content sbom.json
    .EXAMPLE
        Get-AITSBOM -Format CycloneDX | Set-Content sbom.cdx.json
    .LINK
        Show-AITriadHelp
    .LINK
        Invoke-PIIAudit
    .LINK
        Show-OSSLicenses
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [switch]$CheckUpdates,

        [switch]$Update,

        [switch]$Force,

        [ValidateSet('Table', 'Json', 'Csv', 'CycloneDX', 'SPDX')]
        [string]$Format = 'Table',

        [string]$RepoRoot = $script:RepoRoot
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    if ($Update) { $CheckUpdates = $true }

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

    # ── 1. PowerShell modules ─────────────────────────────────────────────────
    Write-Verbose 'Scanning PowerShell modules...'

    $ManifestPath = Join-Path $script:ModuleRoot 'AITriad.psd1'
    if (Test-Path $ManifestPath) {
        try {
            $Manifest = Import-PowerShellDataFile -Path $ManifestPath
            if ($Manifest.ContainsKey('RequiredModules') -and $Manifest.RequiredModules) {
                foreach ($Req in $Manifest.RequiredModules) {
                    if ($Req -is [string]) { $ModName = $Req } else { $ModName = $Req.ModuleName }
                    if ($Req -is [hashtable] -and $Req.ModuleVersion) { $ModVer = $Req.ModuleVersion } else { $ModVer = $null }
                    if (-not $ModVer) {
                        $Installed = Get-Module -ListAvailable -Name $ModName -ErrorAction SilentlyContinue | Select-Object -First 1
                        if ($Installed) { $ModVer = $Installed.Version.ToString() } else { $ModVer = 'not installed' }
                    }
                    $Entries.Add([PSCustomObject]@{
                        Name          = $ModName
                        Version       = $ModVer
                        LatestVersion = $null
                        Status        = $null
                        Type          = 'ps-module'
                        Scope         = 'required'
                        Source        = 'AITriad.psd1 RequiredModules'
                        SourceUrl     = "https://www.powershellgallery.com/packages/$ModName/"
                        License       = $null
                        Supplier      = $null
                        Description   = $null
                        Hash          = $null
                        InstalledVia  = 'PSGallery'
                    })
                }
            }
        }
        catch {
            Write-Warning "Failed to read AITriad.psd1: $($_.Exception.Message)"
        }
    }

    # Companion modules
    foreach ($Companion in @('AIEnrich', 'DocConverters', 'PdfOptimizer')) {
        $CompPath = Join-Path (Join-Path $script:ModuleRoot '..') "$Companion.psm1"
        $CompVer = 'present'
        if (Test-Path $CompPath) {
            $PsdPath = $CompPath -replace '\.psm1$', '.psd1'
            if (Test-Path $PsdPath) {
                try {
                    $CompManifest = Import-PowerShellDataFile -Path $PsdPath
                    $CompVer = $CompManifest.ModuleVersion
                }
                catch { }
            }
        }
        else {
            $CompVer = 'not found'
        }

        $Entries.Add([PSCustomObject]@{
            Name          = $Companion
            Version       = $CompVer
            LatestVersion = $null
            Status        = $null
            Type          = 'ps-module'
            Scope         = 'required'
            Source        = "scripts/$Companion.psm1"
            SourceUrl     = $null
            License       = 'MIT'
            Supplier      = 'AI Triad Research'
            Description   = $null
            Hash          = $null
            InstalledVia  = 'project'
        })
    }

    # ── 2. Node.js packages ───────────────────────────────────────────────────
    Write-Verbose 'Scanning Node.js packages...'

    $AppDirs = @('taxonomy-editor', 'poviewer', 'summary-viewer', 'workflow-app', 'lib')
    $RootPkg = Join-Path $RepoRoot 'package.json'
    if (Test-Path $RootPkg) { $AppDirs = @('') + $AppDirs }

    foreach ($AppDir in $AppDirs) {
        if ($AppDir) { $PkgPath = Join-Path (Join-Path $RepoRoot $AppDir) 'package.json' } else { $PkgPath = $RootPkg }
        if (-not (Test-Path $PkgPath)) { continue }

        if ($AppDir) { $SourceLabel = "$AppDir/package.json" } else { $SourceLabel = 'package.json' }
        try {
            $Pkg = Get-Content -Raw -Path $PkgPath | ConvertFrom-Json

            foreach ($DepType in @('dependencies', 'devDependencies')) {
                if (-not $Pkg.PSObject.Properties[$DepType]) { continue }
                $DepScope = if ($DepType -eq 'devDependencies') { 'development' } else { 'required' }
                $PkgType  = if ($DepType -eq 'devDependencies') { 'npm-dev' } else { 'npm' }
                foreach ($Prop in $Pkg.$DepType.PSObject.Properties) {
                    $CleanVer = $Prop.Value -replace '[\^~>=<]', ''
                    $Entries.Add([PSCustomObject]@{
                        Name          = $Prop.Name
                        Version       = $CleanVer
                        LatestVersion = $null
                        Status        = $null
                        Type          = $PkgType
                        Scope         = $DepScope
                        Source        = $SourceLabel
                        SourceUrl     = "https://www.npmjs.com/package/$($Prop.Name)"
                        License       = $null
                        Supplier      = $null
                        Description   = $null
                        Hash          = $null
                        InstalledVia  = 'npm'
                    })
                }
            }
        }
        catch {
            Write-Warning "Failed to parse $SourceLabel`: $($_.Exception.Message)"
        }
    }

    # ── 3. Python packages ────────────────────────────────────────────────────
    Write-Verbose 'Scanning Python packages...'

    $ReqPath = Join-Path (Join-Path $RepoRoot 'scripts') 'requirements.txt'
    if (Test-Path $ReqPath) {
        $Lines = Get-Content -Path $ReqPath
        foreach ($Line in $Lines) {
            $Line = $Line.Trim()
            if (-not $Line -or $Line.StartsWith('#')) { continue }
            if ($Line -match '^([a-zA-Z0-9_.\-]+(?:\[[^\]]+\])?)(?:[><=!~]+(.+))?$') {
                $PkgName = $Matches[1]
                if ($Matches[2]) { $PkgVer = $Matches[2] } else { $PkgVer = 'any' }
                $PyUrlName = $PkgName -replace '\[.*\]', ''
                $Entries.Add([PSCustomObject]@{
                    Name          = $PkgName
                    Version       = $PkgVer
                    LatestVersion = $null
                    Status        = $null
                    Type          = 'python'
                    Scope         = 'required'
                    Source        = 'scripts/requirements.txt'
                    SourceUrl     = "https://pypi.org/project/$PyUrlName/"
                    License       = $null
                    Supplier      = $null
                    Description   = $null
                    Hash          = $null
                    InstalledVia  = 'pip'
                })
            }
        }
    }

    # ── 4. System tools ───────────────────────────────────────────────────────
    Write-Verbose 'Scanning system tools...'

    $SystemTools = @(
        @{ Name = 'git';        Url = 'https://git-scm.com';                         VersionCmd = { (git --version) -replace 'git version\s*', '' } }
        @{ Name = 'node';       Url = 'https://nodejs.org';                           VersionCmd = { (node --version) -replace '^v', '' } }
        @{ Name = 'npm';        Url = 'https://www.npmjs.com';                        VersionCmd = { npm --version } }
        @{ Name = 'python';     Url = 'https://www.python.org';                       VersionCmd = { if (Get-Command python -EA SilentlyContinue) { $Cmd = 'python' } else { $Cmd = 'python3' }; (& $Cmd --version 2>&1) -replace 'Python\s*', '' } }
        @{ Name = 'pip';        Url = 'https://pip.pypa.io';                          VersionCmd = { if (Get-Command pip -EA SilentlyContinue) { $Cmd = 'pip' } else { $Cmd = 'pip3' }; (& $Cmd --version 2>&1) -replace 'pip\s+(\S+).*', '$1' } }
        @{ Name = 'pandoc';     Url = 'https://pandoc.org';                           VersionCmd = { pandoc --version | Select-Object -First 1 | ForEach-Object { $_ -replace 'pandoc\s*', '' } } }
        @{ Name = 'markitdown'; Url = 'https://github.com/microsoft/markitdown';      VersionCmd = { 'present' } }
        @{ Name = 'gs';         Url = 'https://www.ghostscript.com';                  VersionCmd = { (gs --version 2>&1) -replace '.*?(\d+\.\d+\S*)', '$1' | Select-Object -First 1 } }
    )

    # Pre-build winget lookup for system tools
    $WingetInstalled = @{}
    if (Get-Command winget -ErrorAction SilentlyContinue) {
        try {
            $WingetRaw = winget list 2>$null
            if ($WingetRaw) {
                foreach ($WLine in $WingetRaw) {
                    if ($WLine -match '^\s*(\S.+?)\s{2,}(\S+\.\S+)\s{2,}(\S+)') {
                        $WingetInstalled[$Matches[2]] = $Matches[3]
                    }
                }
            }
        }
        catch { }
    }

    $WingetIdMap = @{
        'git'    = 'Git.Git'
        'node'   = 'OpenJS.NodeJS.LTS'
        'python' = 'Python.Python.3.12'
        'pandoc' = 'JohnMacFarlane.Pandoc'
    }

    foreach ($Tool in $SystemTools) {
        $ToolVer = 'not found'
        $Cmd = Get-Command $Tool.Name -ErrorAction SilentlyContinue
        if ($Cmd) {
            try { $ToolVer = & $Tool.VersionCmd }
            catch { $ToolVer = 'installed (version unknown)' }
        }

        $ToolInstaller = $null
        if ($WingetIdMap.ContainsKey($Tool.Name) -and $WingetInstalled.ContainsKey($WingetIdMap[$Tool.Name])) {
            $ToolInstaller = "winget ($($WingetIdMap[$Tool.Name]))"
        }
        elseif ($Tool.Name -in @('npm'))       { $ToolInstaller = 'bundled (node)' }
        elseif ($Tool.Name -in @('pip'))       { $ToolInstaller = 'bundled (python)' }
        elseif ($Tool.Name -in @('markitdown')) { $ToolInstaller = 'pip' }

        $Entries.Add([PSCustomObject]@{
            Name          = $Tool.Name
            Version       = $ToolVer
            LatestVersion = $null
            Status        = $null
            Type          = 'system'
            Scope         = 'required'
            Source        = 'system PATH'
            SourceUrl     = $Tool.Url
            License       = $null
            Supplier      = $null
            Description   = $null
            Hash          = $null
            InstalledVia  = $ToolInstaller
        })
    }

    # ── 5. AI models ──────────────────────────────────────────────────────────
    Write-Verbose 'Scanning AI models...'

    $ModelsPath = Join-Path $RepoRoot 'ai-models.json'
    if (Test-Path $ModelsPath) {
        try {
            $ModelConfig = Get-Content -Raw -Path $ModelsPath | ConvertFrom-Json
            foreach ($Model in $ModelConfig.models) {
                $ModelUrl = $null
                if ($Model.PSObject.Properties['backend']) {
                    $ModelUrl = switch ($Model.backend) {
                        'gemini'    { "https://ai.google.dev/models/$($Model.id)" }
                        'anthropic' { "https://docs.anthropic.com/en/docs/about-claude/models" }
                        'groq'      { "https://console.groq.com/docs/models" }
                        'openai'    { "https://platform.openai.com/docs/models/$($Model.id)" }
                        default     { $null }
                    }
                }
                $ModelSupplier = if ($Model.PSObject.Properties['backend']) { $Model.backend } else { $null }
                $Entries.Add([PSCustomObject]@{
                    Name          = $Model.id
                    Version       = if ($Model.PSObject.Properties['version']) { $Model.version } else { 'latest' }
                    LatestVersion = $null
                    Status        = $null
                    Type          = 'ai-model'
                    Scope         = 'required'
                    Source        = 'ai-models.json'
                    SourceUrl     = $ModelUrl
                    License       = if ($Model.PSObject.Properties['license']) { $Model.license } else { $null }
                    Supplier      = $ModelSupplier
                    Description   = if ($Model.PSObject.Properties['display_name']) { $Model.display_name } else { $null }
                    Hash          = $null
                    InstalledVia  = 'API'
                })
            }
        }
        catch {
            Write-Warning "Failed to parse ai-models.json: $($_.Exception.Message)"
        }
    }

    # ── 6. Schemas ────────────────────────────────────────────────────────────
    Write-Verbose 'Scanning schemas...'

    $SchemaDir = Join-Path (Join-Path $RepoRoot 'taxonomy') 'schemas'
    if (Test-Path $SchemaDir) {
        foreach ($SchemaFile in Get-ChildItem -Path $SchemaDir -Filter '*.schema.json' -File) {
            $SchemaVer = 'unknown'
            try {
                $Schema = Get-Content -Raw -Path $SchemaFile.FullName | ConvertFrom-Json
                if ($Schema.PSObject.Properties['version']) { $SchemaVer = $Schema.version }
                elseif ($Schema.PSObject.Properties['$schema']) { $SchemaVer = 'json-schema' }
            }
            catch { }

            $Entries.Add([PSCustomObject]@{
                Name          = $SchemaFile.BaseName
                Version       = $SchemaVer
                LatestVersion = $null
                Status        = $null
                Type          = 'schema'
                Scope         = 'required'
                Source        = "taxonomy/schemas/$($SchemaFile.Name)"
                SourceUrl     = $null
                License       = 'MIT'
                Supplier      = 'AI Triad Research'
                Description   = $null
                Hash          = $null
                InstalledVia  = 'project'
            })
        }
    }

    # ── 7. Local metadata enrichment (no network) ─────────────────────────────
    Write-Verbose 'Enriching from local metadata...'

    # 7a. npm: parse lock files for license, integrity hash, download URL
    $NpmEntries = @($Entries | Where-Object { $_.Type -in @('npm', 'npm-dev') })
    if ($NpmEntries.Count -gt 0) {
        $LockCache = @{}
        $ProcessedSources = [System.Collections.Generic.HashSet[string]]::new()

        foreach ($NpmEntry in $NpmEntries) {
            $SourceKey = $NpmEntry.Source -replace '/package\.json$', ''
            if (-not $ProcessedSources.Add($SourceKey)) { continue }

            if ($SourceKey -eq 'package.json') { $LockPath = Join-Path $RepoRoot 'package-lock.json' }
            else { $LockPath = Join-Path (Join-Path $RepoRoot $SourceKey) 'package-lock.json' }

            if (-not (Test-Path $LockPath)) { continue }

            try {
                $Lock = Get-Content -Raw -Path $LockPath | ConvertFrom-Json -AsHashtable
                if ($Lock.ContainsKey('packages')) {
                    foreach ($Key in $Lock.packages.Keys) {
                        if (-not $Key.StartsWith('node_modules/')) { continue }
                        $PkgNameFromLock = $Key.Substring('node_modules/'.Length)
                        $LockCache["$SourceKey|$PkgNameFromLock"] = $Lock.packages[$Key]
                    }
                }
            }
            catch {
                Write-Verbose "Could not parse $LockPath`: $($_.Exception.Message)"
            }
        }

        foreach ($NpmEntry in $NpmEntries) {
            $SourceKey = $NpmEntry.Source -replace '/package\.json$', ''
            $CacheKey = "$SourceKey|$($NpmEntry.Name)"
            if ($LockCache.ContainsKey($CacheKey)) {
                $LockData = $LockCache[$CacheKey]
                if ($LockData.ContainsKey('license') -and $LockData.license)       { $NpmEntry.License   = $LockData.license }
                if ($LockData.ContainsKey('integrity') -and $LockData.integrity)   { $NpmEntry.Hash      = $LockData.integrity }
                if ($LockData.ContainsKey('resolved') -and $LockData.resolved)     { $NpmEntry.SourceUrl = $LockData.resolved }
                if ($LockData.ContainsKey('version') -and $LockData.version)       { $NpmEntry.Version   = $LockData.version }
            }
        }
    }

    # 7b. Python: batch pip show for license, author, description
    $PyEntries = @($Entries | Where-Object { $_.Type -eq 'python' })
    if ($PyEntries.Count -gt 0) {
        $PyCmd = if (Get-Command pip -ErrorAction SilentlyContinue) { 'pip' } else { 'pip3' }
        $PyNames = @($PyEntries | ForEach-Object { $_.Name -replace '\[.*\]', '' })
        try {
            $PipOutput = & $PyCmd show @PyNames 2>$null
            if ($PipOutput) {
                $PipBlocks = @{}
                $CurrentName = $null
                $CurrentBlock = @{}
                foreach ($PipLine in $PipOutput) {
                    if ($PipLine -match '^---') {
                        if ($CurrentName) { $PipBlocks[$CurrentName.ToLower()] = $CurrentBlock }
                        $CurrentName = $null
                        $CurrentBlock = @{}
                        continue
                    }
                    if ($PipLine -match '^([^:]+):\s*(.*)$') {
                        $FieldName = $Matches[1].Trim()
                        $FieldVal  = $Matches[2].Trim()
                        $CurrentBlock[$FieldName] = $FieldVal
                        if ($FieldName -eq 'Name') { $CurrentName = $FieldVal }
                    }
                }
                if ($CurrentName) { $PipBlocks[$CurrentName.ToLower()] = $CurrentBlock }

                foreach ($PyEntry in $PyEntries) {
                    $LookupName = ($PyEntry.Name -replace '\[.*\]', '').ToLower()
                    if ($PipBlocks.ContainsKey($LookupName)) {
                        $Info = $PipBlocks[$LookupName]
                        if ($Info.ContainsKey('License') -and $Info.License -and $Info.License -ne 'UNKNOWN') {
                            $PyEntry.License = $Info.License
                        }
                        if ($Info.ContainsKey('Author') -and $Info.Author -and $Info.Author -ne 'UNKNOWN') {
                            $PyEntry.Supplier = $Info.Author
                        }
                        if ($Info.ContainsKey('Summary') -and $Info.Summary -and $Info.Summary -ne 'UNKNOWN') {
                            $PyEntry.Description = $Info.Summary
                        }
                        if ($Info.ContainsKey('Version') -and $Info.Version) {
                            $PyEntry.Version = $Info.Version
                        }
                    }
                }
            }
        }
        catch {
            Write-Verbose "pip show failed: $($_.Exception.Message)"
        }
    }

    # ── CheckUpdates ──────────────────────────────────────────────────────────
    if ($CheckUpdates) {
        Write-Verbose 'Checking for updates...'

        foreach ($Entry in $Entries) {
            switch ($Entry.Type) {
                'npm' {
                    try {
                        $Latest = Invoke-WithRecovery -Goal "check npm registry for $($Entry.Name)" `
                            -Location 'Get-AITSBOM' -MaxRetries 1 -RetryDelaySeconds 2 `
                            -Action {
                                $Result = npm view $Entry.Name version 2>$null
                                if ($LASTEXITCODE -ne 0) { throw "npm view failed" }
                                $Result.Trim()
                            } `
                            -NextSteps @('Check network connectivity', 'Verify npm is installed')
                        $Entry.LatestVersion = $Latest
                        $Entry.Status = if ($Entry.Version -eq $Latest) { 'up-to-date' } else { 'outdated' }
                    }
                    catch { $Entry.Status = 'unknown' }
                }
                'npm-dev' {
                    try {
                        $Latest = (npm view $Entry.Name version 2>$null)
                        if ($Latest) {
                            $Entry.LatestVersion = $Latest.Trim()
                            $Entry.Status = if ($Entry.Version -eq $Entry.LatestVersion) { 'up-to-date' } else { 'outdated' }
                        }
                        else { $Entry.Status = 'unknown' }
                    }
                    catch { $Entry.Status = 'unknown' }
                }
                'python' {
                    try {
                        $PkgName = $Entry.Name -replace '\[.*\]', ''
                        if (Get-Command pip -EA SilentlyContinue) { $PyCmd = 'pip' } else { $PyCmd = 'pip3' }
                        $Info = & $PyCmd index versions $PkgName 2>$null
                        if ($Info -match 'Available versions:\s*(.+)') {
                            $Latest = ($Matches[1] -split ',\s*')[0].Trim()
                            $Entry.LatestVersion = $Latest
                            $Entry.Status = if ($Entry.Version -ge $Latest) { 'up-to-date' } else { 'outdated' }
                        }
                        else { $Entry.Status = 'unknown' }
                    }
                    catch { $Entry.Status = 'unknown' }
                }
                'ps-module' {
                    try {
                        $Found = Find-Module -Name $Entry.Name -ErrorAction SilentlyContinue | Select-Object -First 1
                        if ($Found) {
                            $Entry.LatestVersion = $Found.Version.ToString()
                            $Entry.Status = if ($Entry.Version -eq $Entry.LatestVersion) { 'up-to-date' } else { 'outdated' }
                        }
                        else { $Entry.Status = 'unknown' }
                    }
                    catch { $Entry.Status = 'unknown' }
                }
                default {
                    $Entry.Status = 'n/a'
                }
            }
        }
    }

    # ── Update ────────────────────────────────────────────────────────────────
    if ($Update) {
        $Outdated = @($Entries | Where-Object { $_.Status -eq 'outdated' })
        if ($Outdated.Count -eq 0) {
            Write-Host ' All packages are up to date.' -ForegroundColor Green
        }
        else {
            Write-Host " $($Outdated.Count) outdated package(s) found:" -ForegroundColor Yellow
            foreach ($Pkg in $Outdated) {
                Write-Host " $($Pkg.Name): $($Pkg.Version) → $($Pkg.LatestVersion) ($($Pkg.Type))" -ForegroundColor Yellow
            }

            if (-not $Force) {
                $Confirm = Read-Host "`n Update all? (y/N)"
                if ($Confirm -notin @('y', 'Y', 'yes')) {
                    Write-Host ' Update cancelled.' -ForegroundColor Gray
                    $Update = $false
                }
            }

            if ($Update) {
                foreach ($Pkg in $Outdated) {
                    try {
                        switch ($Pkg.Type) {
                            { $_ -in @('npm', 'npm-dev') } {
                                $AppDir = ($Pkg.Source -split '/')[0]
                                if ($AppDir -eq 'package.json') { $WorkDir = $RepoRoot } else { $WorkDir = Join-Path $RepoRoot $AppDir }
                                if ($PSCmdlet.ShouldProcess($Pkg.Name, "npm update in $AppDir")) {
                                    Push-Location $WorkDir
                                    npm update $Pkg.Name 2>&1 | Out-Null
                                    Pop-Location
                                    Write-Host " Updated $($Pkg.Name)" -ForegroundColor Green
                                }
                            }
                            'python' {
                                $PkgName = $Pkg.Name -replace '\[.*\]', ''
                                if (Get-Command pip -EA SilentlyContinue) { $PyCmd = 'pip' } else { $PyCmd = 'pip3' }
                                if ($PSCmdlet.ShouldProcess($PkgName, 'pip install --upgrade')) {
                                    & $PyCmd install --upgrade $PkgName 2>&1 | Out-Null
                                    Write-Host " Updated $PkgName" -ForegroundColor Green
                                }
                            }
                            'ps-module' {
                                if ($PSCmdlet.ShouldProcess($Pkg.Name, 'Update-Module')) {
                                    Update-Module -Name $Pkg.Name -Force
                                    Write-Host " Updated $($Pkg.Name)" -ForegroundColor Green
                                }
                            }
                            default {
                                Write-Verbose " Skipping $($Pkg.Name) ($($Pkg.Type)) — manual update required"
                            }
                        }
                    }
                    catch {
                        New-ActionableError -Goal "update $($Pkg.Name)" `
                            -Problem $_.Exception.Message `
                            -Location 'Get-AITSBOM -Update' `
                            -NextSteps @(
                                "Try manually: update $($Pkg.Name) via $($Pkg.Type) package manager",
                                'Check network connectivity'
                            )
                    }
                }
            }
        }
    }

    # ── Output formatting ─────────────────────────────────────────────────────
    $BaseFields = @('Name', 'Version', 'Type', 'Scope', 'License', 'Supplier', 'InstalledVia', 'Source', 'SourceUrl', 'Description', 'Hash')
    if ($CheckUpdates) {
        $AllFields = @('Name', 'Version', 'LatestVersion', 'Status') + @('Type', 'Scope', 'License', 'Supplier', 'InstalledVia', 'Source', 'SourceUrl', 'Description', 'Hash')
        $OutputEntries = $Entries | Select-Object $AllFields
    }
    else {
        $OutputEntries = $Entries | Select-Object $BaseFields
    }

    switch ($Format) {
        'Table' {
            return $Entries
        }
        'Json' {
            return ($OutputEntries | ConvertTo-Json -Depth 5)
        }
        'Csv' {
            return ($OutputEntries | ConvertTo-Csv -NoTypeInformation)
        }
        'CycloneDX' {
            $Components = @($Entries | ForEach-Object {
                $PurlType = switch ($_.Type) {
                    'npm'       { 'npm' }
                    'npm-dev'   { 'npm' }
                    'python'    { 'pypi' }
                    'ps-module' { 'nuget' }
                    default     { 'generic' }
                }
                $Comp = [ordered]@{
                    type    = 'library'
                    name    = $_.Name
                    version = $_.Version
                    scope   = if ($_.Scope -eq 'development') { 'excluded' } else { 'required' }
                    purl    = "pkg:$PurlType/$($_.Name)@$($_.Version)"
                }
                if ($_.Description) { $Comp['description'] = $_.Description }
                if ($_.Supplier)    { $Comp['supplier'] = [ordered]@{ name = $_.Supplier } }
                if ($_.License)     { $Comp['licenses'] = @( [ordered]@{ license = [ordered]@{ id = $_.License } } ) }
                if ($_.Hash) {
                    $HashAlg = if ($_.Hash -match '^sha512-') { 'SHA-512' }
                               elseif ($_.Hash -match '^sha256-') { 'SHA-256' }
                               elseif ($_.Hash -match '^sha1-') { 'SHA-1' }
                               else { 'SHA-512' }
                    $HashVal = $_.Hash -replace '^sha\d+-', ''
                    $Comp['hashes'] = @( [ordered]@{ alg = $HashAlg; content = $HashVal } )
                }
                $ExtRefs = [System.Collections.Generic.List[hashtable]]::new()
                if ($_.SourceUrl) { $ExtRefs.Add([ordered]@{ type = 'distribution'; url = $_.SourceUrl }) }
                if ($ExtRefs.Count -gt 0) { $Comp['externalReferences'] = @($ExtRefs) }
                $Comp['properties'] = @(
                    [ordered]@{ name = 'source'; value = $_.Source }
                    [ordered]@{ name = 'component-type'; value = $_.Type }
                )
                $Comp
            })

            $CycloneDX = [ordered]@{
                bomFormat   = 'CycloneDX'
                specVersion = '1.5'
                version     = 1
                metadata    = [ordered]@{
                    timestamp = (Get-Date).ToString('o')
                    component = [ordered]@{
                        type    = 'application'
                        name    = 'ai-triad-research'
                        version = (Import-PowerShellDataFile -Path $ManifestPath).ModuleVersion
                    }
                }
                components  = $Components
            }

            return ($CycloneDX | ConvertTo-Json -Depth 10)
        }
        'SPDX' {
            $Packages = @($Entries | ForEach-Object {
                $PurlType = switch ($_.Type) {
                    'npm'       { 'npm' }
                    'npm-dev'   { 'npm' }
                    'python'    { 'pypi' }
                    'ps-module' { 'nuget' }
                    default     { 'generic' }
                }
                $SpdxPkg = [ordered]@{
                    SPDXID               = "SPDXRef-$($_.Name -replace '[^a-zA-Z0-9._-]', '-')"
                    name                 = $_.Name
                    versionInfo          = $_.Version
                    downloadLocation     = if ($_.SourceUrl) { $_.SourceUrl } else { 'NOASSERTION' }
                    filesAnalyzed        = $false
                    supplier             = if ($_.Supplier) { "Organization: $($_.Supplier)" } else { 'NOASSERTION' }
                    description          = if ($_.Description) { $_.Description } else { $null }
                    primaryPackagePurpose = if ($_.Scope -eq 'development') { 'DOCUMENTATION' } else { 'LIBRARY' }
                    externalRefs         = @(
                        [ordered]@{
                            referenceCategory = 'PACKAGE-MANAGER'
                            referenceType     = 'purl'
                            referenceLocator  = "pkg:$PurlType/$($_.Name)@$($_.Version)"
                        }
                    )
                }
                if ($_.License) {
                    $SpdxPkg['licenseConcluded'] = $_.License
                    $SpdxPkg['licenseDeclared']  = $_.License
                }
                else {
                    $SpdxPkg['licenseConcluded'] = 'NOASSERTION'
                    $SpdxPkg['licenseDeclared']  = 'NOASSERTION'
                }
                if ($_.Hash) {
                    $HashAlg = if ($_.Hash -match '^sha512-') { 'SHA512' }
                               elseif ($_.Hash -match '^sha256-') { 'SHA256' }
                               elseif ($_.Hash -match '^sha1-') { 'SHA1' }
                               else { 'SHA512' }
                    $HashVal = $_.Hash -replace '^sha\d+-', ''
                    $SpdxPkg['checksums'] = @( [ordered]@{ algorithm = $HashAlg; checksumValue = $HashVal } )
                }
                $SpdxPkg
            })

            $SPDX = [ordered]@{
                spdxVersion       = 'SPDX-2.3'
                dataLicense       = 'CC0-1.0'
                SPDXID            = 'SPDXRef-DOCUMENT'
                name              = 'ai-triad-research-sbom'
                documentNamespace = "https://spdx.org/spdxdocs/ai-triad-research-$(New-Guid)"
                creationInfo      = [ordered]@{
                    created  = (Get-Date).ToString('o')
                    creators = @('Tool: Get-AITSBOM')
                }
                packages          = $Packages
            }

            return ($SPDX | ConvertTo-Json -Depth 10)
        }
    }
}