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.
 
        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 -Update -Force
    .EXAMPLE
        Get-AITSBOM -Format CycloneDX | Set-Content sbom.cdx.json
    #>

    [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...'

    # From AITriad.psd1 RequiredModules
    $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'
                        Source        = 'AITriad.psd1 RequiredModules'
                        License       = $null
                    })
                }
            }
        }
        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'
            Source        = "scripts/$Companion.psm1"
            License       = 'MIT'
        })
    }

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

    $AppDirs = @('taxonomy-editor', 'poviewer', 'summary-viewer')
    # Root package.json (shared 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 }
                foreach ($Prop in $Pkg.$DepType.PSObject.Properties) {
                    $CleanVer = $Prop.Value -replace '[\^~>=<]', ''
                    if ($DepType -eq 'devDependencies') { $PkgType = 'npm-dev' } else { $PkgType = 'npm' }
                    $Entries.Add([PSCustomObject]@{
                        Name          = $Prop.Name
                        Version       = $CleanVer
                        LatestVersion = $null
                        Status        = $null
                        Type          = $PkgType
                        Source        = $SourceLabel
                        License       = $null
                    })
                }
            }
        }
        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 }
            # Parse: package>=version or package[extras]>=version
            if ($Line -match '^([a-zA-Z0-9_.\-]+(?:\[[^\]]+\])?)(?:[><=!~]+(.+))?$') {
                $PkgName = $Matches[1]
                if ($Matches[2]) { $PkgVer = $Matches[2] } else { $PkgVer = 'any' }
                $Entries.Add([PSCustomObject]@{
                    Name          = $PkgName
                    Version       = $PkgVer
                    LatestVersion = $null
                    Status        = $null
                    Type          = 'python'
                    Source        = 'scripts/requirements.txt'
                    License       = $null
                })
            }
        }
    }

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

    $SystemTools = @(
        @{ Name = 'git';       VersionCmd = { (git --version) -replace 'git version\s*', '' } }
        @{ Name = 'node';      VersionCmd = { (node --version) -replace '^v', '' } }
        @{ Name = 'npm';       VersionCmd = { npm --version } }
        @{ Name = 'python';    VersionCmd = { if (Get-Command python -EA SilentlyContinue) { $Cmd = 'python' } else { $Cmd = 'python3' }; (& $Cmd --version 2>&1) -replace 'Python\s*', '' } }
        @{ Name = 'pip';       VersionCmd = { if (Get-Command pip -EA SilentlyContinue) { $Cmd = 'pip' } else { $Cmd = 'pip3' }; (& $Cmd --version 2>&1) -replace 'pip\s+(\S+).*', '$1' } }
        @{ Name = 'pandoc';    VersionCmd = { pandoc --version | Select-Object -First 1 | ForEach-Object { $_ -replace 'pandoc\s*', '' } } }
        @{ Name = 'markitdown'; VersionCmd = { 'present' } }
    )

    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)' }
        }

        $Entries.Add([PSCustomObject]@{
            Name          = $Tool.Name
            Version       = $ToolVer
            LatestVersion = $null
            Status        = $null
            Type          = 'system'
            Source        = 'system PATH'
            License       = $null
        })
    }

    # ── 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) {
                $Entries.Add([PSCustomObject]@{
                    Name          = $Model.id
                    Version       = if ($Model.PSObject.Properties['version']) { $Model.version } else { 'latest' }
                    LatestVersion = $null
                    Status        = $null
                    Type          = 'ai-model'
                    Source        = 'ai-models.json'
                    License       = if ($Model.PSObject.Properties['license']) { $Model.license } else { $null }
                })
            }
        }
        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'
                Source        = "taxonomy/schemas/$($SchemaFile.Name)"
                License       = $null
            })
        }
    }

    # ── 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 '\[.*\]', ''  # Strip extras
                        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') } {
                                # Determine which app dir
                                $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 ─────────────────────────────────────────────────────
    if ($CheckUpdates) {
        $OutputEntries = $Entries | Select-Object Name, Version, LatestVersion, Status, Type, Source, License
    }
    else {
        $OutputEntries = $Entries | Select-Object Name, Version, Type, Source, License
    }

    switch ($Format) {
        'Table' {
            $OutputEntries | Format-Table -AutoSize | Out-Host
            return $Entries
        }
        'Json' {
            return ($OutputEntries | ConvertTo-Json -Depth 5)
        }
        'Csv' {
            return ($OutputEntries | ConvertTo-Csv -NoTypeInformation)
        }
        'CycloneDX' {
            # CycloneDX 1.5 JSON format
            $Components = @($Entries | ForEach-Object {
                $PurlType = switch ($_.Type) {
                    'npm'       { 'npm' }
                    'npm-dev'   { 'npm' }
                    'python'    { 'pypi' }
                    'ps-module' { 'nuget' }
                    default     { 'generic' }
                }
                [ordered]@{
                    type    = 'library'
                    name    = $_.Name
                    version = $_.Version
                    purl    = "pkg:$PurlType/$($_.Name)@$($_.Version)"
                    properties = @(
                        [ordered]@{ name = 'source'; value = $_.Source }
                        [ordered]@{ name = 'component-type'; value = $_.Type }
                    )
                }
                if ($_.License) {
                    # Add license to last component
                }
            })

            $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' {
            # SPDX 2.3 JSON format
            $Packages = @($Entries | ForEach-Object {
                [ordered]@{
                    SPDXID               = "SPDXRef-$($_.Name -replace '[^a-zA-Z0-9._-]', '-')"
                    name                 = $_.Name
                    versionInfo          = $_.Version
                    downloadLocation     = 'NOASSERTION'
                    filesAnalyzed        = $false
                    supplier             = 'NOASSERTION'
                    externalRefs         = @(
                        [ordered]@{
                            referenceCategory = 'PACKAGE-MANAGER'
                            referenceType     = 'purl'
                            referenceLocator  = "pkg:generic/$($_.Name)@$($_.Version)"
                        }
                    )
                }
            })

            $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)
        }
    }
}