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