|
| 1 | +<# |
| 2 | +.SYNOPSIS |
| 3 | +Parity gate for kiota's PowerShellWrapper generator. |
| 4 | +
|
| 5 | +.DESCRIPTION |
| 6 | +See the kiota repo: src/Kiota.Builder/PowerShellWrapper/README.md, sections 4 and 6. |
| 7 | +
|
| 8 | +For every generated *.g.cs cmdlet file, reconstructs the HTTP method and URI from the |
| 9 | +client.<BuilderExpression>.<Method>Async( call chain (CmdletEmitter.cs's templates; the |
| 10 | +chain shape comes from CmdletNaming.BuildBuilderExpression), joins it against the |
| 11 | +Method+Uri -> Command inventory in MgCommandMetadata.json, and reports whether the |
| 12 | +emitted [Cmdlet(...)] name matches what the oracle says the published SDK calls that |
| 13 | +operation. |
| 14 | +
|
| 15 | +Dispatcher cmdlets (the paired-GET public cmdlet that only forwards to its internal |
| 16 | +_List/_Get siblings via InvokeCommand.InvokeScript - see CmdletEmitter.EmitGetDispatcher) |
| 17 | +contain no direct Graph call, so there is nothing to reconstruct from their source; they |
| 18 | +are reported separately per module and excluded from the match ratio. Their two internal |
| 19 | +siblings do carry real calls and are what actually gets checked against the oracle. |
| 20 | +
|
| 21 | +Path parameter names are not exactly recoverable from the generated C#: CmdletNaming |
| 22 | +.ExtractPathParamNames PascalCases each hyphen-chunk of a raw "{some-id}" segment and |
| 23 | +concatenates with no separator kept, so e.g. "conditionalAccessPolicy-id" and a |
| 24 | +hypothetical "conditionalAccess-policy-id" would both produce the indexer name |
| 25 | +"ConditionalAccessPolicyId" - the hyphen position inside a multi-word param name is lost. |
| 26 | +Fixed path segments do not have this problem (BuildBuilderExpression only ever uppercases |
| 27 | +the first character of a whole segment, which is exactly reversible). So this script |
| 28 | +compares fixed segments exactly and normalizes every path parameter, on both the |
| 29 | +generated and the oracle side, to a single "{param}" placeholder before joining. |
| 30 | +
|
| 31 | +The oracle carries both v1.0 and beta rows, and many operations have the identical |
| 32 | +Method+URI in both (only the Command prefix differs: Get-MgUserMessage vs |
| 33 | +Get-MgBetaUserMessage) - joining on Method+URI alone is ambiguous. Each module's |
| 34 | +kiota-lock.json (written next to its *.g.cs files) records the descriptionLocation the |
| 35 | +module was generated from, e.g. "../../openApiDocs/v1.0/Mail.yml"; this script reads the |
| 36 | +v1.0/beta segment out of that path and scopes the oracle lookup to just that ApiVersion. |
| 37 | +A module folder with no kiota-lock.json (or an unrecognized descriptionLocation) falls |
| 38 | +back to searching every ApiVersion and reports a real ambiguity if more than one distinct |
| 39 | +command turns up. |
| 40 | +
|
| 41 | +.PARAMETER GeneratedPath |
| 42 | +A folder of generated *.g.cs files for one module, or a folder containing one |
| 43 | +subfolder per module (each with its own *.g.cs files) - e.g. the repo's generated/. |
| 44 | +
|
| 45 | +.PARAMETER OraclePath |
| 46 | +Path to MgCommandMetadata.json. |
| 47 | +
|
| 48 | +.EXAMPLE |
| 49 | +.\tools\Compare-WrapperCmdletNames.ps1 |
| 50 | +.\tools\Compare-WrapperCmdletNames.ps1 -GeneratedPath generated\Mail |
| 51 | +#> |
| 52 | +[CmdletBinding()] |
| 53 | +param( |
| 54 | + [string]$GeneratedPath = (Join-Path $PSScriptRoot '..\generated'), |
| 55 | + [string]$OraclePath = (Join-Path $PSScriptRoot '..\src\Authentication\Authentication\custom\common\MgCommandMetadata.json') |
| 56 | +) |
| 57 | + |
| 58 | +$ErrorActionPreference = 'Stop' |
| 59 | + |
| 60 | +if (-not (Test-Path $GeneratedPath)) { |
| 61 | + Write-Error "Generated path not found: $GeneratedPath" |
| 62 | + exit 1 |
| 63 | +} |
| 64 | +if (-not (Test-Path $OraclePath)) { |
| 65 | + Write-Error "Oracle file not found: $OraclePath" |
| 66 | + exit 1 |
| 67 | +} |
| 68 | + |
| 69 | +# One folder full of *.g.cs directly, or one folder per module. Handles both so the script |
| 70 | +# works whether -GeneratedPath points at generated/ or at generated/Mail directly. |
| 71 | +function Get-WrapperModuleFolders { |
| 72 | + param([string]$Root) |
| 73 | + |
| 74 | + $rootItem = Get-Item -Path $Root |
| 75 | + $direct = Get-ChildItem -Path $rootItem.FullName -Filter '*.g.cs' -File -ErrorAction SilentlyContinue |
| 76 | + if ($direct) { |
| 77 | + return @([pscustomobject]@{ Name = $rootItem.Name; Path = $rootItem.FullName }) |
| 78 | + } |
| 79 | + |
| 80 | + Get-ChildItem -Path $rootItem.FullName -Directory -ErrorAction SilentlyContinue | ForEach-Object { |
| 81 | + if (Get-ChildItem -Path $_.FullName -Filter '*.g.cs' -File -ErrorAction SilentlyContinue) { |
| 82 | + [pscustomobject]@{ Name = $_.Name; Path = $_.FullName } |
| 83 | + } |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +function ConvertTo-NormalizedOracleUri { |
| 88 | + param([string]$Uri) |
| 89 | + $parts = $Uri.Trim('/') -split '/' |
| 90 | + $norm = foreach ($p in $parts) { |
| 91 | + if ($p -match '^\{.*\}$') { '{param}' } else { $p } |
| 92 | + } |
| 93 | + '/' + ($norm -join '/') |
| 94 | +} |
| 95 | + |
| 96 | +function ConvertTo-NormalizedGeneratedUri { |
| 97 | + param([string]$BuilderExpression) |
| 98 | + $segments = @() |
| 99 | + foreach ($token in ($BuilderExpression -split '\.')) { |
| 100 | + if ($token -notmatch '^([A-Za-z0-9]+)(\[([A-Za-z0-9]+)\])?$') { |
| 101 | + return $null |
| 102 | + } |
| 103 | + $prop = $Matches[1] |
| 104 | + $segments += ($prop.Substring(0, 1).ToLowerInvariant() + $prop.Substring(1)) |
| 105 | + if ($Matches[3]) { $segments += '{param}' } |
| 106 | + } |
| 107 | + '/' + ($segments -join '/') |
| 108 | +} |
| 109 | + |
| 110 | +# Reads the v1.0/beta segment out of a module's kiota-lock.json ("descriptionLocation": |
| 111 | +# "../../openApiDocs/v1.0/Mail.yml"), so the oracle join can be scoped to the ApiVersion |
| 112 | +# the module was actually generated from instead of guessing. |
| 113 | +function Get-ModuleApiVersion { |
| 114 | + param([string]$ModulePath) |
| 115 | + $lockPath = Join-Path $ModulePath 'kiota-lock.json' |
| 116 | + if (-not (Test-Path $lockPath)) { return $null } |
| 117 | + $loc = (Get-Content -Path $lockPath -Raw | ConvertFrom-Json).descriptionLocation |
| 118 | + if (-not $loc) { return $null } |
| 119 | + if ($loc -match '(^|[\\/])v1\.0([\\/]|$)') { return 'v1.0' } |
| 120 | + if ($loc -match '(^|[\\/])beta([\\/]|$)') { return 'beta' } |
| 121 | + return $null |
| 122 | +} |
| 123 | + |
| 124 | +Write-Host "Loading oracle from $OraclePath ..." |
| 125 | +$oracle = Get-Content -Path $OraclePath -Raw | ConvertFrom-Json |
| 126 | + |
| 127 | +$oracleIndex = @{} |
| 128 | +foreach ($entry in $oracle) { |
| 129 | + if (-not $entry.Method -or -not $entry.Uri -or -not $entry.Command -or -not $entry.ApiVersion) { continue } |
| 130 | + $key = "$($entry.ApiVersion)|$($entry.Method)|$(ConvertTo-NormalizedOracleUri $entry.Uri)" |
| 131 | + if (-not $oracleIndex.ContainsKey($key)) { |
| 132 | + $oracleIndex[$key] = [System.Collections.Generic.HashSet[string]]::new() |
| 133 | + } |
| 134 | + [void]$oracleIndex[$key].Add($entry.Command) |
| 135 | +} |
| 136 | +Write-Host "Indexed $($oracle.Count) oracle entries into $($oracleIndex.Count) ApiVersion+Method+URI keys." |
| 137 | +Write-Host '' |
| 138 | + |
| 139 | +# Looks up one Method+URI, scoped to $ApiVersion when known; falls back to searching every |
| 140 | +# ApiVersion (and merging what turns up) when the module's own version couldn't be |
| 141 | +# determined, so an unresolvable version shows up as a real ambiguity, not a false match. |
| 142 | +function Find-OracleCommands { |
| 143 | + param([hashtable]$Index, [string]$ApiVersion, [string]$Method, [string]$NormalizedUri) |
| 144 | + if ($ApiVersion) { |
| 145 | + return $Index["$ApiVersion|$Method|$NormalizedUri"] |
| 146 | + } |
| 147 | + $merged = [System.Collections.Generic.HashSet[string]]::new() |
| 148 | + foreach ($v in 'v1.0', 'beta') { |
| 149 | + $found = $Index["$v|$Method|$NormalizedUri"] |
| 150 | + if ($found) { [void]$merged.UnionWith($found) } |
| 151 | + } |
| 152 | + if ($merged.Count -eq 0) { return $null } |
| 153 | + return $merged |
| 154 | +} |
| 155 | + |
| 156 | +$cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"([^"]+)"' |
| 157 | +$callChainPattern = 'client\.([A-Za-z0-9_.\[\]]+)\.(Get|Post|Patch|Put|Delete)Async\(' |
| 158 | + |
| 159 | +$modules = @(Get-WrapperModuleFolders -Root $GeneratedPath) |
| 160 | +if ($modules.Count -eq 0) { |
| 161 | + Write-Error "No *.g.cs files found under $GeneratedPath" |
| 162 | + exit 1 |
| 163 | +} |
| 164 | + |
| 165 | +$totalJoinable = 0 |
| 166 | +$totalMatched = 0 |
| 167 | +$totalMismatches = 0 |
| 168 | +$totalDispatchers = 0 |
| 169 | + |
| 170 | +foreach ($module in $modules | Sort-Object Name) { |
| 171 | + $files = Get-ChildItem -Path $module.Path -Filter '*.g.cs' -File | Sort-Object Name |
| 172 | + $apiVersion = Get-ModuleApiVersion -ModulePath $module.Path |
| 173 | + $moduleJoinable = 0 |
| 174 | + $moduleMatched = 0 |
| 175 | + $moduleDispatchers = 0 |
| 176 | + $moduleProblems = @() |
| 177 | + |
| 178 | + foreach ($file in $files) { |
| 179 | + $content = Get-Content -Path $file.FullName -Raw |
| 180 | + |
| 181 | + $attrMatch = [regex]::Match($content, $cmdletAttrPattern) |
| 182 | + if (-not $attrMatch.Success) { |
| 183 | + continue # not a cmdlet file (Shared.g.cs) |
| 184 | + } |
| 185 | + $verb = $attrMatch.Groups[1].Value |
| 186 | + $generatedNoun = $attrMatch.Groups[2].Value |
| 187 | + $publishedNoun = $generatedNoun -replace '_(List|Get)$', '' |
| 188 | + $generatedCommand = "$verb-$generatedNoun" |
| 189 | + $expectedCommand = "$verb-$publishedNoun" |
| 190 | + |
| 191 | + $callMatch = [regex]::Match($content, $callChainPattern) |
| 192 | + if (-not $callMatch.Success) { |
| 193 | + $moduleDispatchers++ |
| 194 | + continue # dispatcher: delegates to internal cmdlets, nothing to reconstruct here |
| 195 | + } |
| 196 | + |
| 197 | + $builderExpr = $callMatch.Groups[1].Value |
| 198 | + $method = $callMatch.Groups[2].Value.ToUpperInvariant() |
| 199 | + $normalizedUri = ConvertTo-NormalizedGeneratedUri -BuilderExpression $builderExpr |
| 200 | + |
| 201 | + $moduleJoinable++ |
| 202 | + |
| 203 | + if (-not $normalizedUri) { |
| 204 | + $moduleProblems += " [UNPARSEABLE] $($file.Name): builder expression '$builderExpr' has a segment this script doesn't recognize (e.g. an OData cast segment - see README section 7, cast endpoints aren't generated yet)." |
| 205 | + continue |
| 206 | + } |
| 207 | + |
| 208 | + $candidates = Find-OracleCommands -Index $oracleIndex -ApiVersion $apiVersion -Method $method -NormalizedUri $normalizedUri |
| 209 | + |
| 210 | + if (-not $candidates -or $candidates.Count -eq 0) { |
| 211 | + $moduleProblems += " [NO ORACLE ENTRY] $($file.Name): '$generatedCommand' -> reconstructed $method $normalizedUri, no oracle row for that Method+URI." |
| 212 | + } |
| 213 | + elseif ($candidates.Count -gt 1) { |
| 214 | + $moduleProblems += " [AMBIGUOUS] $($file.Name): $method $normalizedUri matches multiple oracle commands: $($candidates -join ', ')." |
| 215 | + } |
| 216 | + elseif ($candidates.Contains($expectedCommand)) { |
| 217 | + $moduleMatched++ |
| 218 | + } |
| 219 | + else { |
| 220 | + $moduleProblems += " [MISMATCH] $($file.Name): generated '$expectedCommand', oracle says '$($candidates | Select-Object -First 1)' for $method $normalizedUri." |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + $status = if ($moduleJoinable -eq 0) { 'n/a' } else { "$moduleMatched of $moduleJoinable" } |
| 225 | + $dispatcherNote = if ($moduleDispatchers -gt 0) { " (+$moduleDispatchers dispatcher cmdlet(s), no direct call to verify)" } else { '' } |
| 226 | + $versionNote = if ($apiVersion) { " [$apiVersion]" } else { ' [ApiVersion unknown - searched all versions]' } |
| 227 | + Write-Host "$($module.Name)$($versionNote): $status cmdlets match the oracle$dispatcherNote" |
| 228 | + foreach ($line in $moduleProblems) { Write-Host $line -ForegroundColor Yellow } |
| 229 | + |
| 230 | + $totalJoinable += $moduleJoinable |
| 231 | + $totalMatched += $moduleMatched |
| 232 | + $totalMismatches += $moduleProblems.Count |
| 233 | + $totalDispatchers += $moduleDispatchers |
| 234 | +} |
| 235 | + |
| 236 | +Write-Host '' |
| 237 | +Write-Host "TOTAL: $totalMatched of $totalJoinable cmdlets match the oracle across $($modules.Count) module(s) (+$totalDispatchers dispatcher cmdlet(s) skipped)." |
| 238 | + |
| 239 | +if ($totalMismatches -gt 0) { |
| 240 | + exit 1 |
| 241 | +} |
| 242 | +exit 0 |
0 commit comments