Skip to content

Commit 2ce6ac8

Browse files
feat(wrapper-generator): add initial PowerShell wrapper generator with cmdlet-name parity checks and unit tests
1 parent 8ecc521 commit 2ce6ac8

21 files changed

Lines changed: 2751 additions & 0 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ BenchmarkDotNet.Artifacts/
6161
project.lock.json
6262
project.fragment.lock.json
6363
artifacts/
64+
sweep_results.json
6465

6566
# StyleCop
6667
StyleCopReport.xml
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System.Collections.Generic;
2+
using WrapperGenerator;
3+
using Xunit;
4+
5+
namespace WrapperGenerator.Tests;
6+
7+
public sealed class EmitterTests
8+
{
9+
// A spec-derived noun or header name containing a double quote must be escaped where it is
10+
// interpolated into a generated C# string literal, or the generated source will not compile.
11+
[Fact]
12+
public void EscapesQuotesInSpecDerivedStringLiterals()
13+
{
14+
var naming = new CmdletNaming(
15+
VerbsClass: "VerbsCommon",
16+
VerbName: "Remove",
17+
Noun: "MgHe\"llo", // quote lands in the [Cmdlet(..., "...")] literal
18+
ClassName: "RemoveMgHelloCommand", // class name is a separate, valid identifier
19+
PathParamNames: new[] { "UserId" },
20+
BuilderExpression: "Users[UserId]",
21+
HeaderParams: new[] { new HeaderParam("If\"Match", "IfMatch") }); // quote in a header name
22+
23+
var source = CmdletEmitter.EmitRemove(naming, new EmitContext("Test.Client"));
24+
25+
// Escaped forms present (valid literals).
26+
Assert.Contains("\"MgHe\\\"llo\"", source); // "MgHe\"llo"
27+
Assert.Contains("\"If\\\"Match\"", source); // "If\"Match"
28+
29+
// Broken/unescaped forms absent.
30+
Assert.DoesNotContain("\"MgHe\"llo\"", source); // "MgHe"llo"
31+
Assert.DoesNotContain("\"If\"Match\"", source); // "If"Match"
32+
}
33+
}

0 commit comments

Comments
 (0)