Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ Generated_Code/
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
# ...but src/BackupRestore is a Graph service module, not a Visual Studio backup folder. The
# rule above excludes the directory itself, so git never descends into it; re-including the
# directory is what makes its generated output committable at all.
!src/BackupRestore/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
Expand Down
30 changes: 25 additions & 5 deletions tools/Build-WrapperModule.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ Get-* dispatchers forward to the workers by name via InvokeCommand.InvokeScript,
manifest that hides the workers breaks dispatch ("term not recognized"). Worker visibility
needs its own dispatch design and is tracked in the module-wiring issue.

Everything is written under artifacts/ (gitignored); nothing this script produces is
committed. To check cmdlet-name parity for a built module, point the parity gate at its
cmdlets folder:
By default everything is written under artifacts/ (gitignored) for throwaway local runs. With
-IntoSource the same pipeline writes the committed layout under src/<Module>/<ApiVersion>/wrapper/,
where the client, the wrappers and the csproj live together so the folder builds standalone.
To check cmdlet-name parity for a built module, point the parity gate at its cmdlets folder:
.\tools\Compare-WrapperCmdletNames.ps1 -GeneratedPath artifacts\wrapper-modules\<Module>\src\Cmdlets

.PARAMETER Module
Expand All @@ -52,11 +53,19 @@ dotnet build configuration. Default: Debug.
.PARAMETER SkipKiota
Reuse the previously generated client (fast inner loop when only the wrappers changed).

.PARAMETER IntoSource
Write the committed layout under src/<Module>/<ApiVersion>/wrapper/ instead of artifacts/:
Client/ + Cmdlets/ + the csproj, self-contained so the folder builds on its own. This is how
the generated output is checked in; omit it for throwaway local builds.

.EXAMPLE
.\tools\Build-WrapperModule.ps1 -Module Mail

.EXAMPLE
.\tools\Build-WrapperModule.ps1 -Module Mail,Calendar -ApiVersion v1.0

.EXAMPLE
.\tools\Build-WrapperModule.ps1 -Module Mail -IntoSource
#>
[CmdletBinding()]
param(
Expand All @@ -67,7 +76,8 @@ param(
[string]$SpecRoot,
[string]$OutputRoot,
[string]$Configuration = 'Debug',
[switch]$SkipKiota
[switch]$SkipKiota,
[switch]$IntoSource
)

$ErrorActionPreference = 'Stop'
Expand Down Expand Up @@ -146,7 +156,17 @@ function Build-Module {

$moduleName = "Microsoft.Graph.Wrapper.$Name"
$clientNs = "Microsoft.Graph.PowerShell.$Name.Client"
$srcDir = Join-Path $OutputRoot "$Name\src"
# -IntoSource writes the committed layout: one self-contained project folder per module
# and API version, holding the kiota client, the wrappers, and the csproj that compiles
# both into one assembly. Everything under it is committable as-is (the module
# .gitignore blocks a csproj at the version-folder root, and still ignores bin/obj at
# any depth). Without the switch, output stays in artifacts/ for throwaway local runs.
$srcDir = if ($IntoSource) {
Join-Path $repoRoot "src\$Name\$ApiVersion\wrapper"
}
else {
Join-Path $OutputRoot "$Name\src"
}
$clientDir = Join-Path $srcDir 'Client'
$cmdletsDir = Join-Path $srcDir 'Cmdlets'
New-Item -ItemType Directory -Force -Path $srcDir | Out-Null
Expand Down
4 changes: 2 additions & 2 deletions tools/Compare-WrapperCmdletNames.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ function Get-ModuleApiVersion {

# Published names the generator deliberately corrects instead of reproducing. Each entry maps
# the shipped (wrong) command to the corrected one the generator emits, and must have a matching
# entry in tools/WrapperGenerator/docs/edge-cases/naming-edge-cases.md and a pinned naming test.
# The gate reports these as [CORRECTED] instead of [MISMATCH] and does not fail on them.
# entry in tools/WrapperGenerator/docs/edge-cases/naming-edge-cases.md and a pinned naming test. The
# gate reports these as [CORRECTED] instead of [MISMATCH] and does not fail on them.
$deliberateCorrections = @{
# AutoRest inflected the trailing /whois segment to "Whoi"; the other 28 whois-family
# cmdlets (whoisRecords, whoisHistoryRecords) all keep "Whois".
Expand Down
105 changes: 105 additions & 0 deletions tools/Compare-WrapperOperationInventory.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<#
.SYNOPSIS
Captures, and compares, the set of operations the generator turns into cmdlets.

.DESCRIPTION
A change meant to affect only cmdlet PARAMETERS must not change which OPERATIONS generate.
Comparing filenames alone cannot show that: two operations could exchange ownership of a
cmdlet name and leave the same set of files behind. This records the full identity of each
emitted cmdlet - module, verb, noun, request path (the kiota builder chain, which is the
operation's path) and file - and diffs two snapshots on that tuple.

Use -Baseline to record the current state before a change, then -Compare afterwards.

.EXAMPLE
.\tools\Compare-WrapperOperationInventory.ps1 -Path artifacts\wrapper-modules -Baseline before.csv
.EXAMPLE
.\tools\Compare-WrapperOperationInventory.ps1 -Path artifacts\wrapper-modules -Baseline before.csv -Compare after.csv
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[string]$Baseline,
[string]$Compare
)

$ErrorActionPreference = 'Stop'

$cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"((?:\\.|[^"\\])*)"'
$builderPattern = 'client\.([A-Za-z0-9_\[\]\.]+?)\.(?:Get|Post|Patch|Delete|Put)Async'

# Finds every Cmdlets folder under the root at any depth and takes the module name from the
# first segment below it, so the artifacts layout (<Module>/src/Cmdlets) and the committed one
# (<Module>/<ApiVersion>/wrapper/Cmdlets) can be compared against each other.
function Get-Inventory([string]$root) {
$rootFull = (Resolve-Path $root).Path
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($cmdletsDir in (Get-ChildItem $rootFull -Directory -Recurse -Filter 'Cmdlets')) {
$relative = $cmdletsDir.FullName.Substring($rootFull.Length).TrimStart('\', '/')
$module = ($relative -split '[\\/]')[0]
foreach ($file in Get-ChildItem $cmdletsDir.FullName -Filter '*.g.cs' -File) {
if ($file.Name -eq 'Shared.g.cs') { continue }
$text = Get-Content $file.FullName -Raw
$m = [regex]::Match($text, $cmdletAttrPattern)
if (-not $m.Success) { continue }
$b = [regex]::Match($text, $builderPattern)
$rows.Add([pscustomobject]@{
Module = $module
Cmdlet = "$($m.Groups[1].Value)-$([regex]::Unescape($m.Groups[2].Value))"
Verb = $m.Groups[1].Value
RequestPath = if ($b.Success) { $b.Groups[1].Value } else { '(dispatcher)' }
File = $file.Name
})
}
}
return $rows | Sort-Object Module, File
}

$inventory = Get-Inventory $Path
# An empty inventory means the path or layout is wrong. Left unchecked it compares nothing
# against nothing and reports "unchanged" - a pass that proves the opposite of what it claims.
if ($inventory.Count -eq 0) {
Write-Error "No cmdlets found under '$Path'. Expected <Module>/src/Cmdlets or <Module>/<ApiVersion>/wrapper/Cmdlets."
exit 2
}

if (-not $Compare) {
$inventory | Export-Csv $Baseline -NoTypeInformation
"baseline: $($inventory.Count) cmdlets -> $Baseline"
exit 0
}

$inventory | Export-Csv $Compare -NoTypeInformation
$before = Import-Csv $Baseline
$after = Import-Csv $Compare

# Identity is the whole tuple, so an operation swapping which cmdlet/file it owns shows up as
# one removal plus one addition rather than as no change at all.
function Key($r) { "{0}|{1}|{2}|{3}" -f $r.Module, $r.Cmdlet, $r.RequestPath, $r.File }
# Filled by Add, and built inline rather than in a helper function, for two separate reasons:
# the HashSet(IEnumerable<string>) constructor is ambiguous against
# HashSet(IEqualityComparer<string>) when a side is empty, and returning a set FROM a function
# makes PowerShell enumerate it back into an Object[] - whose Contains is a linear scan, turning
# this comparison into ~n^2 string compares over ~10k identities.
$beforeKeys = [System.Collections.Generic.HashSet[string]]::new()
foreach ($r in $before) { [void]$beforeKeys.Add((Key $r)) }
$afterKeys = [System.Collections.Generic.HashSet[string]]::new()
foreach ($r in $after) { [void]$afterKeys.Add((Key $r)) }

$added = @($afterKeys | Where-Object { -not $beforeKeys.Contains($_) })
$removed = @($beforeKeys | Where-Object { -not $afterKeys.Contains($_) })

"before: $($before.Count) cmdlets"
"after: $($after.Count) cmdlets"
"added: $($added.Count)"
"removed: $($removed.Count)"
if ($added) { ""; "ADDED:"; $added | Select-Object -First 25 | ForEach-Object { " $_" } }
if ($removed) { ""; "REMOVED:"; $removed | Select-Object -First 25 | ForEach-Object { " $_" } }

if ($added.Count -eq 0 -and $removed.Count -eq 0) {
""; "operation inventory unchanged."
exit 0
}
exit 1
106 changes: 106 additions & 0 deletions tools/Measure-BodyPropertyCoverage.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<#
.SYNOPSIS
Reports how request-body properties classify across every module: bound, excluded, or
unsupported and why.

.DESCRIPTION
Runs the generator over each spec and reads the per-property diagnostics it emits, so the
numbers come from the same classifier that decides what gets bound - not a second
reimplementation that could disagree with it.

Output is a per-shape rollup (which unsupported shapes are worth implementing next) and a
per-module CSV. Required-but-unbound properties are called out separately: those are the ones
that make a cmdlet unable to complete its request at all.

.EXAMPLE
.\tools\Measure-BodyPropertyCoverage.ps1
#>
[CmdletBinding()]
param(
[ValidateSet('v1.0', 'beta')]
[string]$ApiVersion = 'v1.0',
[string]$OutCsv
)

$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
if (-not $OutCsv) { $OutCsv = Join-Path $repoRoot "artifacts\body-property-coverage.$ApiVersion.csv" }

$specRoot = Join-Path $repoRoot "openApiDocs_KiotaCompat\$ApiVersion"
$generator = Join-Path $repoRoot 'tools\WrapperGenerator'
$scratch = Join-Path $repoRoot "artifacts\body-coverage-scratch"
New-Item -ItemType Directory -Force $scratch | Out-Null
New-Item -ItemType Directory -Force (Split-Path $OutCsv) | Out-Null

$rows = [System.Collections.Generic.List[object]]::new()
$specs = @(Get-ChildItem "$specRoot\*.yml" | Sort-Object Name)
# A run over no specs would report "0 unbound" - a clean bill of health from having measured
# nothing, which is the failure mode this whole sweep exists to avoid.
if ($specs.Count -eq 0) { Write-Error "No specs found under '$specRoot'."; exit 2 }
$failedSpecs = [System.Collections.Generic.List[string]]::new()

foreach ($spec in $specs) {
$module = $spec.BaseName
$out = Join-Path $scratch $module
Remove-Item $out -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $out | Out-Null

# Information level so the per-property diagnostics are emitted.
$log = & dotnet run --project $generator -c Release -- `
-d $spec.FullName -o $out -n "Microsoft.Graph.PowerShell.$module.Client" --api-version $ApiVersion --log-level Information 2>&1
# A module that failed to generate emits no diagnostics, so its properties would silently
# count as zero unbound and flatter the total.
if ($LASTEXITCODE -ne 0) {
$failedSpecs.Add("$module (exit $LASTEXITCODE)")
Write-Warning "$module : generation failed; excluded from the totals"
continue
}

foreach ($line in $log) {
if ("$line" -match 'Unbound body property (?<noun>[^.]+)\.(?<prop>\S+): (?<shape>\w+) \(required=(?<req>\w+)\)') {
$rows.Add([pscustomobject]@{
Module = $module
Noun = $Matches.noun
Property = $Matches.prop
Shape = $Matches.shape
Required = [bool]::Parse($Matches.req)
})
}
}
Write-Host ("{0,-34} unbound: {1}" -f $module, @($rows | Where-Object Module -eq $module).Count)
}

# A CSV written from a partial sweep reads exactly like a complete one, so it is only produced
# when every spec generated. The population is stated beside the totals for the same reason.
if ($failedSpecs.Count -gt 0) {
Write-Error "$($failedSpecs.Count) of $($specs.Count) specs failed to generate: $($failedSpecs -join ', '). No CSV written - these totals would understate the unbound surface."
exit 1
}
$rows | Export-Csv $OutCsv -NoTypeInformation

# The generator reports a property per OPERATION, so an inherited property on a widely reused
# model repeats across every cmdlet that binds it. Both figures matter and mean different
# things: occurrences size the noise in a run, distinct identities size the actual work.
$identity = { "$($_.Module)|$($_.Noun)|$($_.Property)|$($_.Shape)" }
$distinct = @($rows | ForEach-Object $identity | Sort-Object -Unique)

""; "=== unbound body properties by shape (occurrences / distinct) ==="
$rows | Group-Object Shape | Sort-Object Count -Descending |
Select-Object Count, Name,
@{n = 'Distinct'; e = { @($_.Group | ForEach-Object $identity | Sort-Object -Unique).Count } } |
Format-Table -AutoSize | Out-String -Width 80

"=== distinct property names per shape (top 8 each) ==="
foreach ($g in ($rows | Group-Object Shape | Sort-Object Count -Descending)) {
$names = ($g.Group | Select-Object -ExpandProperty Property -Unique | Select-Object -First 8) -join ', '
" {0,-16} {1}" -f $g.Name, $names
}

""; "specs generated: $($specs.Count) of $($specs.Count)"
"total unbound occurrences: $($rows.Count)"
"distinct module/noun/prop/shape: $($distinct.Count)"
# Graph marks almost nothing required in its schemas (the overwhelming majority of required
# blocks list only @odata.type), so this count is reported for completeness and is not
# evidence that nothing important is unbound.
"flagged required in the spec: $(@($rows | Where-Object Required -eq 'True').Count)"
"csv: $OutCsv"
98 changes: 98 additions & 0 deletions tools/New-WrapperOutputManifest.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<#
.SYNOPSIS
Writes a reviewable inventory of the committed wrapper output under src/<Module>/<ApiVersion>/wrapper/.

.DESCRIPTION
The committed output is tens of thousands of generated files - far past what GitHub renders in
a diff and far past what anyone reads. This emits the summary a reviewer actually can read:
one CSV row per exported cmdlet (module, verb, noun, request path, source file) plus a
per-module rollup, so "what does this generator produce" and "what changed since last time"
are answerable from a diff of two small files instead of a diff of the tree.

Cmdlet names come from the emitted [Cmdlet(VerbsX.Verb, "Noun")] attribute and the request
path from the emitted kiota builder chain - the generated source is the source of truth, so
the manifest cannot drift from what the module will actually export.

Internal *_Get/*_List workers are listed with IsWorker = True: they are real emitted files but
not part of the surface a user calls, and separating them keeps the cmdlet count honest. (The
psd1 currently exports them anyway - the dispatcher resolves them by name at runtime - which is
a dispatch-design question tracked with the module-wiring work, not a manifest concern.)

.EXAMPLE
.\tools\New-WrapperOutputManifest.ps1
.EXAMPLE
.\tools\New-WrapperOutputManifest.ps1 -ApiVersion v1.0
#>
[CmdletBinding()]
param(
[ValidateSet('v1.0', 'beta')]
[string]$ApiVersion = 'v1.0',
[string]$OutDir
)

$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
# docs/ already holds versioned CSV inventories of the shipped surface
# (PowerShellBreakingChanges-V1.0.csv); this follows that placement and naming.
if (-not $OutDir) { $OutDir = Join-Path $repoRoot 'docs' }
$versionTag = if ($ApiVersion -eq 'v1.0') { 'V1.0' } else { 'Beta' }

# Same attribute pattern the parity gate and Build-WrapperModule use.
$cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"((?:\\.|[^"\\])*)"'
# The kiota chain the cmdlet calls, e.g. "client.Users[UserId].Messages.GetAsync()".
$builderPattern = 'client\.([A-Za-z0-9_\[\]\.]+?)\.(?:Get|Post|Patch|Delete|Put)Async'

$rows = [System.Collections.Generic.List[object]]::new()
$moduleDirs = Get-ChildItem (Join-Path $repoRoot 'src') -Directory |
ForEach-Object { Join-Path $_.FullName "$ApiVersion\wrapper\Cmdlets" } |
Where-Object { Test-Path $_ }

foreach ($dir in $moduleDirs) {
$module = (Get-Item $dir).Parent.Parent.Parent.Name
foreach ($file in Get-ChildItem $dir -Filter '*.g.cs' -File) {
if ($file.Name -eq 'Shared.g.cs') { continue }
$text = Get-Content $file.FullName -Raw
$m = [regex]::Match($text, $cmdletAttrPattern)
if (-not $m.Success) { continue }
$b = [regex]::Match($text, $builderPattern)
# A dispatcher makes no request itself - it forwards to its _Get/_List workers - so an
# absent builder chain identifies one rather than indicating a parse failure.
$isWorker = $file.Name -match '_(Get|List)\.g\.cs$'
$rows.Add([pscustomobject]@{
Module = $module
ApiVersion = $ApiVersion
Cmdlet = "$($m.Groups[1].Value)-$([regex]::Unescape($m.Groups[2].Value))"
Verb = $m.Groups[1].Value
Noun = [regex]::Unescape($m.Groups[2].Value)
RequestPath = if ($b.Success) { $b.Groups[1].Value } elseif (-not $isWorker) { '(dispatcher)' } else { '' }
IsWorker = $isWorker
File = $file.Name
})
}
}

if ($rows.Count -eq 0) { throw "No committed wrapper output found for $ApiVersion under src/*/$ApiVersion/wrapper/Cmdlets." }

$manifestPath = Join-Path $OutDir "WrapperCmdlets-$versionTag.csv"
$rows | Sort-Object Module, Cmdlet, File | Export-Csv $manifestPath -NoTypeInformation

$summaryPath = Join-Path $OutDir "WrapperCmdlets-$versionTag-Summary.csv"
$rows | Group-Object Module | ForEach-Object {
$public = @($_.Group | Where-Object { -not $_.IsWorker })
[pscustomobject]@{
Module = $_.Name
Cmdlets = $public.Count
WorkerFiles = $_.Count - $public.Count
Get = @($public | Where-Object Verb -eq 'Get').Count
New = @($public | Where-Object Verb -eq 'New').Count
Update = @($public | Where-Object Verb -eq 'Update').Count
Remove = @($public | Where-Object Verb -eq 'Remove').Count
}
} | Sort-Object Cmdlets -Descending | Export-Csv $summaryPath -NoTypeInformation

$publicTotal = @($rows | Where-Object { -not $_.IsWorker }).Count
"modules: $($rows | Group-Object Module | Measure-Object | Select-Object -ExpandProperty Count)"
"public cmdlets: $publicTotal"
"worker files: $($rows.Count - $publicTotal)"
"wrote $manifestPath"
"wrote $summaryPath"
Loading
Loading