Public/Get-KritOpenApiCoverage.ps1

function Get-KritOpenApiCoverage {
<#
.SYNOPSIS
    Diff OpenAPI spec endpoints vs the PowerShell function inventory of a
    generated Krit.<Brand>OpenApi module.

.DESCRIPTION
    Detects drift between an upstream OpenAPI spec and its generated PS client.
    Useful as a CI gate after upstream publishes a new spec revision.

    For each spec operation, computes the canonical PS function name
    (Verb-Brand+OperationId pattern matching Lens-OpenApiToPsModule-1507.mjs)
    and checks whether the generated module exports it. Conversely, flags
    functions in the module that no longer map to any spec op (likely from a
    removed endpoint).

.PARAMETER Spec
    Path to OpenAPI spec.

.PARAMETER GeneratedModule
    Path to the generated .psm1 (or psd1) under test.

.PARAMETER Brand
    Brand prefix used at generate-time (e.g. Pax8). Used to compute canonical
    function names.

.EXAMPLE
    Get-KritOpenApiCoverage -Spec ./pax8-openapi.json `
        -GeneratedModule ./Krit.Pax8OpenApi.psm1 -Brand Pax8
#>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Spec,
        [Parameter(Mandatory)][string]$GeneratedModule,
        [Parameter(Mandatory)][string]$Brand
    )

    if (-not (Test-Path -LiteralPath $Spec))           { throw "Spec not found: $Spec" }
    if (-not (Test-Path -LiteralPath $GeneratedModule)){ throw "GeneratedModule not found: $GeneratedModule" }

    $doc = Get-Content -LiteralPath $Spec -Raw | ConvertFrom-Json -Depth 100

    $verbMap = @{
        get='Get'; post='New'; put='Set'; delete='Remove'; patch='Update'; head='Test'
    }

    function ConvertTo-Pascal([string]$s) {
        if (-not $s) { return '' }
        (($s -replace '[^A-Za-z0-9]+',' ').Trim() -split '\s+' | ForEach-Object {
            if ($_.Length -gt 0) { $_.Substring(0,1).ToUpper() + $_.Substring(1).ToLower() }
        }) -join ''
    }

    $expected = @{}
    foreach ($pathKey in $doc.paths.PSObject.Properties.Name) {
        $pathItem = $doc.paths.$pathKey
        foreach ($verb in $verbMap.Keys) {
            if ($pathItem.PSObject.Properties.Name -notcontains $verb) { continue }
            $op = $pathItem.$verb
            $opId = "$verb $pathKey"
            if ($op -and $op.PSObject -and ($op.PSObject.Properties.Name -contains 'operationId') -and -not [string]::IsNullOrWhiteSpace($op.operationId)) {
                $opId = $op.operationId
            }
            $fnName = "$($verbMap[$verb])-$Brand$(ConvertTo-Pascal $opId)"
            if (-not $expected.ContainsKey($fnName)) {
                $expected[$fnName] = "$($verb.ToUpper()) $pathKey"
            }
        }
    }

    $modName = [System.IO.Path]::GetFileNameWithoutExtension($GeneratedModule)
    Import-Module $GeneratedModule -Force -ErrorAction Stop
    $actual = (Get-Command -Module $modName -CommandType Function).Name

    $missing = $expected.Keys | Where-Object { $_ -notin $actual } | Sort-Object
    $extra   = $actual           | Where-Object { $_ -notin $expected.Keys } | Sort-Object

    [pscustomobject]@{
        Spec           = (Resolve-Path -LiteralPath $Spec).Path
        Module         = (Resolve-Path -LiteralPath $GeneratedModule).Path
        Brand          = $Brand
        ExpectedCount  = $expected.Count
        ActualCount    = $actual.Count
        MissingFunctions = $missing
        ExtraFunctions   = $extra
        CoveragePercent  = if ($expected.Count -gt 0) {
            [math]::Round((($expected.Count - $missing.Count) / $expected.Count) * 100, 2)
        } else { 0 }
        CheckedAtUtc     = (Get-Date).ToUniversalTime().ToString('o')
    }
}