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
35 changes: 32 additions & 3 deletions tools/Compare-WrapperCmdletNames.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ Method+Uri -> Command inventory in MgCommandMetadata.json, and reports whether t
emitted [Cmdlet(...)] name matches what the oracle says the published SDK calls that
operation.

A small set of published names are known AutoRest defects the generator deliberately
corrects instead of reproducing (tools/WrapperGenerator/docs/edge-cases/naming-edge-cases.md
is the catalog). Those are matched against the $deliberateCorrections table below and
reported as [CORRECTED] rather than [MISMATCH]; they do not fail the gate.

Dispatcher cmdlets (the paired-GET public cmdlet that only forwards to its internal
_List/_Get siblings via InvokeCommand.InvokeScript - see CmdletEmitter.EmitGetDispatcher)
contain no direct Graph call, so there is nothing to reconstruct from their source; they
Expand Down Expand Up @@ -126,6 +131,17 @@ function Get-ModuleApiVersion {
return $null
}

# 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.
$deliberateCorrections = @{
Comment thread
Joywambui-maina marked this conversation as resolved.
# AutoRest inflected the trailing /whois segment to "Whoi"; the other 28 whois-family
# cmdlets (whoisRecords, whoisHistoryRecords) all keep "Whois".
'Get-MgSecurityThreatIntelligenceHostWhoi' = 'Get-MgSecurityThreatIntelligenceHostWhois'
'Get-MgBetaSecurityThreatIntelligenceHostWhoi' = 'Get-MgBetaSecurityThreatIntelligenceHostWhois'
}

Write-Host "Loading oracle from $OraclePath ..."
$oracle = Get-Content -Path $OraclePath -Raw | ConvertFrom-Json

Expand Down Expand Up @@ -174,6 +190,7 @@ $totalMatched = 0
$totalMismatches = 0
$totalDispatchers = 0
$totalUnparseable = 0
$totalCorrected = 0

foreach ($module in $modules | Sort-Object Name) {
$files = Get-ChildItem -Path $module.Path -Filter '*.g.cs' -File | Sort-Object Name
Expand All @@ -182,7 +199,9 @@ foreach ($module in $modules | Sort-Object Name) {
$moduleMatched = 0
$moduleDispatchers = 0
$moduleUnparseable = 0
$moduleCorrected = 0
$moduleSkips = @()
$moduleCorrections = @()
$moduleProblems = @()

foreach ($file in $files) {
Expand Down Expand Up @@ -238,27 +257,37 @@ foreach ($module in $modules | Sort-Object Name) {
$moduleMatched++
}
else {
$moduleProblems += " [MISMATCH] $($file.Name): generated '$expectedCommand', oracle says '$($candidates | Select-Object -First 1)' for $method $normalizedUri."
$oracleCommand = $candidates | Select-Object -First 1
if ($deliberateCorrections[$oracleCommand] -eq $expectedCommand) {
$moduleCorrected++
$moduleCorrections += " [CORRECTED] $($file.Name): oracle ships '$oracleCommand'; generator deliberately emits '$expectedCommand' (see tools/WrapperGenerator/docs/edge-cases/naming-edge-cases.md)."
}
else {
$moduleProblems += " [MISMATCH] $($file.Name): generated '$expectedCommand', oracle says '$oracleCommand' for $method $normalizedUri."
}
}
}

$status = if ($moduleJoinable -eq 0) { 'n/a' } else { "$moduleMatched of $moduleJoinable" }
$dispatcherNote = if ($moduleDispatchers -gt 0) { " (+$moduleDispatchers dispatcher cmdlet(s), no direct call to verify)" } else { '' }
$castNote = if ($moduleUnparseable -gt 0) { " (+$moduleUnparseable cast cmdlet(s) skipped, not generated end to end yet)" } else { '' }
$correctedNote = if ($moduleCorrected -gt 0) { " (+$moduleCorrected deliberately corrected name(s))" } else { '' }
$versionNote = if ($apiVersion) { " [$apiVersion]" } else { ' [ApiVersion unknown - searched all versions]' }
Write-Host "$($module.Name)$($versionNote): $status cmdlets match the oracle$dispatcherNote$castNote"
Write-Host "$($module.Name)$($versionNote): $status cmdlets match the oracle$dispatcherNote$castNote$correctedNote"
foreach ($line in $moduleSkips) { Write-Host $line -ForegroundColor DarkYellow }
foreach ($line in $moduleCorrections) { Write-Host $line -ForegroundColor DarkCyan }
foreach ($line in $moduleProblems) { Write-Host $line -ForegroundColor Yellow }

$totalJoinable += $moduleJoinable
$totalMatched += $moduleMatched
$totalMismatches += $moduleProblems.Count
$totalDispatchers += $moduleDispatchers
$totalUnparseable += $moduleUnparseable
$totalCorrected += $moduleCorrected
}

Write-Host ''
Write-Host "TOTAL: $totalMatched of $totalJoinable cmdlets match the oracle across $($modules.Count) module(s) (+$totalDispatchers dispatcher cmdlet(s) skipped, +$totalUnparseable cast cmdlet(s) skipped)."
Write-Host "TOTAL: $totalMatched of $totalJoinable cmdlets match the oracle across $($modules.Count) module(s) (+$totalDispatchers dispatcher cmdlet(s) skipped, +$totalUnparseable cast cmdlet(s) skipped, +$totalCorrected deliberately corrected)."

if ($totalMismatches -gt 0) {
exit 1
Expand Down
38 changes: 36 additions & 2 deletions tools/WrapperGenerator.Tests/NamingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,29 @@ public sealed class SingularizerTests
[InlineData("Access", "Access")]
[InlineData("Status", "Status")]
[InlineData("Analysis", "Analysis")]
// "Whois" also hits the is-guard — a deliberate correction, not a parity pin: the SDK
// ships Get-MgSecurityThreatIntelligenceHostWhoi (AutoRest inflected the trailing
// "whois" segment) while its 28 whoisRecords/whoisHistoryRecords siblings keep "Whois".
// See docs/edge-cases/naming-edge-cases.md.
[InlineData("Whois", "Whois")]
// plain s
[InlineData("Messages", "Message")]
[InlineData("Plans", "Plan")]
[InlineData("Settings", "Setting")]
[InlineData("Licenses", "License")]
// irregulars (Get-MgDriveItemChild, Get-MgUserPerson)
// irregulars (Get-MgDriveItemChild, Get-MgUserPerson,
// Get-MgSecurityThreatIntelligenceHostCookie, Get-MgSubscribedSku)
[InlineData("Children", "Child")]
[InlineData("People", "Person")]
// invariants (Get-MgUserSettingWindows)
[InlineData("Cookies", "Cookie")]
[InlineData("Skus", "Sku")]
// invariants (Get-MgUserSettingWindows, Get-MgDomainVerificationDnsRecord,
// Get-MgDeviceAppManagementIosManagedAppProtection,
// Get-MgSecurityCaseEdiscoveryCaseSearchLastEstimateStatisticsOperation)
[InlineData("Windows", "Windows")]
[InlineData("Dns", "Dns")]
[InlineData("Ios", "Ios")]
[InlineData("Statistics", "Statistics")]
// acronyms are never plural forms
[InlineData("OS", "OS")]
public void SingularizesWords(string word, string expected)
Expand All @@ -54,6 +67,8 @@ public void SingularizesWords(string word, string expected)
[InlineData("OnPremisesSynchronization", "OnPremiseSynchronization")]
// version tag: Get-MgSecurityAlertV2
[InlineData("Alerts_v2", "AlertV2")]
// interior "Whois" survives per-word inflection (Get-MgSecurityThreatIntelligenceWhoisHistoryRecord)
[InlineData("WhoisHistoryRecords", "WhoisHistoryRecord")]
public void SingularizesSegments(string segment, string expected)
{
Assert.Equal(expected, Singularizer.SingularizeSegment(segment));
Expand All @@ -80,6 +95,9 @@ private static CmdletNaming Resolve(string method, string path) =>
[InlineData("GET", "/identity/conditionalAccess/policies/{conditionalAccessPolicy-id}", "Get", "MgIdentityConditionalAccessPolicy")]
[InlineData("GET", "/planner/plans", "Get", "MgPlannerPlan")]
[InlineData("GET", "/security/alerts_v2", "Get", "MgSecurityAlertV2")]
[InlineData("GET", "/security/threatIntelligence/whoisRecords/{whoisRecord-id}", "Get", "MgSecurityThreatIntelligenceWhoisRecord")]
// interior "Statistics" survives per-word inflection (invariant found via the DEVX API's Humanizer exception list)
[InlineData("GET", "/security/cases/ediscoveryCases/{ediscoveryCase-id}/searches/{ediscoverySearch-id}/lastEstimateStatisticsOperation", "Get", "MgSecurityCaseEdiscoveryCaseSearchLastEstimateStatisticsOperation")]
[InlineData("PATCH", "/admin/reportSettings", "Update", "MgAdminReportSetting")]
[InlineData("GET", "/schemaExtensions", "Get", "MgSchemaExtension")]
[InlineData("GET", "/domains/{domain-id}", "Get", "MgDomain")]
Expand All @@ -103,6 +121,22 @@ public void ResolvesPublishedSdkNames(string method, string path, string expecte
Assert.Equal($"{expectedVerb}{expectedNoun}Command", naming.ClassName);
}

[Theory]
// Deliberate corrections: the published name is wrong (an AutoRest naming defect) and the
// generator emits the corrected name instead of reproducing it. Every entry here must have
// a docs/edge-cases/naming-edge-cases.md entry and a matching row in
// Compare-WrapperCmdletNames.ps1's $deliberateCorrections table, so the parity gate
// reports it as [CORRECTED], not a failure.
// Shipped: Get-MgSecurityThreatIntelligenceHostWhoi — the only whois-family cmdlet (of 30)
// where "Whois" was inflected to "Whoi".
[InlineData("GET", "/security/threatIntelligence/hosts/{host-id}/whois", "Get", "MgSecurityThreatIntelligenceHostWhois")]
public void AppliesDeliberateNameCorrections(string method, string path, string expectedVerb, string expectedNoun)
{
var naming = Resolve(method, path);
Assert.Equal(expectedVerb, naming.VerbName);
Assert.Equal(expectedNoun, naming.Noun);
}

[Theory]
// The builder expression is the Kiota request-builder chain the emitted cmdlet calls
// (client.<expr>.GetAsync()). A property per fixed segment, an indexer per path parameter.
Expand Down
9 changes: 9 additions & 0 deletions tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ public void MapsNumericFormatsWithoutDataLoss()
["sizeInBytes"] = Scalar(JsonSchemaType.Integer, format: "int64"), // values > 2^31 must survive
["retryCount"] = Scalar(JsonSchemaType.Integer, format: "int32"),
["plainCount"] = Scalar(JsonSchemaType.Integer),
// Graph's docs declare Edm.Int32/Int64 as type "number" with the format carrying
// the real type (mailFolder.childFolderCount, messageRule.sequence). The format
// must win or the parameter type contradicts the Kiota model and won't compile.
["childFolderCount"] = Scalar(JsonSchemaType.Number, format: "int32"),
["quotaUsed"] = Scalar(JsonSchemaType.Number, format: "int64"),
["confidence"] = Scalar(JsonSchemaType.Number, format: "float"),
},
};

Expand All @@ -96,6 +102,9 @@ public void MapsNumericFormatsWithoutDataLoss()
Assert.Equal("long", props.Single(p => p.OpenApiName == "sizeInBytes").PsTypeName);
Assert.Equal("int", props.Single(p => p.OpenApiName == "retryCount").PsTypeName);
Assert.Equal("int", props.Single(p => p.OpenApiName == "plainCount").PsTypeName);
Assert.Equal("int", props.Single(p => p.OpenApiName == "childFolderCount").PsTypeName);
Assert.Equal("long", props.Single(p => p.OpenApiName == "quotaUsed").PsTypeName);
Assert.Equal("float", props.Single(p => p.OpenApiName == "confidence").PsTypeName);
}

[Fact]
Expand Down
8 changes: 4 additions & 4 deletions tools/WrapperGenerator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Generates the PowerShell **cmdlets** for the Microsoft Graph SDK from Graph's Op

The Microsoft Graph PowerShell SDK is thousands of cmdlets, and customers have scripts that depend on their exact names — `Get-MgUserMessage`, not `Get-MgUsersMessages`. Those names follow conventions, but the conventions are fiddly (singular nouns, a `Mg` prefix, a handful of hand-tuned exceptions), and the SDK's current generator (AutoRest) has quietly dropped cmdlets when names collided.

This tool regenerates those cmdlets from the same OpenAPI description **while reproducing the published names exactly**, so a regenerated module is a drop-in replacement. Because name parity is the hard part, most of the tool is a naming engine; the rest is a straightforward C# code emitter.
This tool regenerates those cmdlets from the same OpenAPI description **while reproducing the published names exactly**, so a regenerated module is a drop-in replacement. Because name parity is the hard part, most of the tool is a naming engine; the rest is a straightforward C# code emitter. The one exception to "exactly": a handful of published names are AutoRest naming defects (e.g. `Get-MgSecurityThreatIntelligenceHostWhoi`, where "Whois" lost its `s`) that the generator deliberately corrects — each is pinned by a test, allowlisted in the parity gate, and documented in the edge-case catalog ([docs/edge-cases/naming-edge-cases.md](docs/edge-cases/naming-edge-cases.md), one file per class of issue).

## What it produces

Expand Down Expand Up @@ -155,16 +155,16 @@ dotnet run --project tools/WrapperGenerator -- `
**Test** — two layers:

```powershell
# 1. Naming rules pinned to published Microsoft.Graph names (69 tests)
# 1. Naming rules pinned to published Microsoft.Graph names
dotnet test tools/WrapperGenerator.Tests
# => Passed! - Failed: 0, Passed: 69, Total: 69
# => Passed! - Failed: 0, Passed: 88, Total: 88

# 2. Parity gate: generate, then check every cmdlet name against Graph's own command inventory
.\tools\Compare-WrapperCmdletNames.ps1 -GeneratedPath <output-folder>
# => Mail [v1.0]: 4 of 4 cmdlets match the oracle ... EXIT CODE: 0
```

The unit tests guard the naming rules (their expected values are real published names from `src/Authentication/Authentication/custom/common/MgCommandMetadata.json`). The parity gate checks actual generated output against that same inventory. There is **no** test yet that the generated cmdlets *compile* — that needs step 1's client to compile against.
The unit tests guard the naming rules (their expected values are real published names from `src/Authentication/Authentication/custom/common/MgCommandMetadata.json`). The parity gate checks actual generated output against that same inventory; names on the deliberate-corrections list ([docs/edge-cases/naming-edge-cases.md](docs/edge-cases/naming-edge-cases.md)) are reported as `[CORRECTED]` instead of failing. There is **no** test yet that the generated cmdlets *compile* — that needs step 1's client to compile against.

## Gaps / not done yet

Expand Down
31 changes: 18 additions & 13 deletions tools/WrapperGenerator/SchemaProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@ namespace WrapperGenerator;
public sealed record CmdletProperty(string OpenApiName, string PascalName, string PsTypeName, bool IsArray);

// Maps a body schema's top-level primitive properties onto cmdlet parameters. Deliberately
// shallow, per team decision: nested complex properties (assignedLicenses, employeeOrgData,
// and the like) are skipped rather than modeled. Two special cases: "id" is excluded because
// the server assigns it, and passwordProfile is flagged separately via HasPasswordProfile
// because creating a user requires it.
// shallow, per team decision: nested complex properties are skipped rather than modeled.
// Server-managed properties are excluded, and passwordProfile is flagged separately via
// HasPasswordProfile.
public static class SchemaProperties
{
public static IReadOnlyList<CmdletProperty> ExtractPrimitiveProperties(IOpenApiSchema schema)
Expand Down Expand Up @@ -45,9 +44,9 @@ void Walk(IOpenApiSchema s)
return result;
}

// passwordProfile is a nested complex type, so ExtractPrimitiveProperties skips it, but
// Graph requires it to create a user. This flag lets the emitter add the two flattened
// parameters (-Password, -ForceChangePasswordNextSignIn) that make New-MgUser usable.
// Detects a passwordProfile property (directly or via allOf) so the emitter can flatten
// it into parameters; Graph requires it to create a user. Generalizing this pattern is
// tracked in #3690.
public static bool HasPasswordProfile(IOpenApiSchema schema)
{
ArgumentNullException.ThrowIfNull(schema);
Expand All @@ -67,16 +66,22 @@ public static bool HasPasswordProfile(IOpenApiSchema schema)
_ => false,
};

// Numeric mapping follows the OpenAPI format so values survive the round trip: an int64
// property must not truncate to int (overflow above ~2.1 billion) and a number property
// must not lose its fraction to integer truncation.
// Numeric mapping: an explicit format decides the CLR type, mirroring Kiota's own
// mapping so a wrapper parameter always matches the Kiota model property it is assigned
// to. Without a format, integer stays int and number stays double.
private static string MapPsType(IOpenApiSchema schema) => (schema.Type & ~JsonSchemaType.Null) switch
{
JsonSchemaType.String => "string",
JsonSchemaType.Boolean => "bool",
JsonSchemaType.Integer when string.Equals(schema.Format, "int64", StringComparison.OrdinalIgnoreCase) => "long",
JsonSchemaType.Integer => "int",
JsonSchemaType.Number => "double",
JsonSchemaType.Integer or JsonSchemaType.Number => schema.Format?.ToLowerInvariant() switch
{
"int64" => "long",
"int32" => "int",
"float" => "float",
"double" => "double",
"decimal" => "decimal",
_ => (schema.Type & ~JsonSchemaType.Null) == JsonSchemaType.Integer ? "int" : "double",
},
_ => "string",
};

Expand Down
14 changes: 10 additions & 4 deletions tools/WrapperGenerator/Singularizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,24 @@ namespace WrapperGenerator;
// it splits a segment into words and runs the rules on each word.
public static partial class Singularizer
{
// Irregular plurals the SDK singularizes: Get-MgDriveItemChild, Get-MgUserPerson.
// Irregular plurals the ordered rules below would inflect wrongly. Evidence for each
// entry lives in the README rule table and docs/edge-cases.
private static readonly Dictionary<string, string> Irregulars = new(StringComparer.Ordinal)
{
["Children"] = "Child",
["People"] = "Person",
["Cookies"] = "Cookie",
["Skus"] = "Sku",
};

// Words that end in "s" but are not plurals. The SDK keeps them as-is:
// /users/{id}/settings/windows ships as Get-MgUserSettingWindows.
// Words that end in "s" but are not plurals; never singularized. Evidence for each
// entry lives in the README rule table and docs/edge-cases.
private static readonly HashSet<string> Invariants = new(StringComparer.Ordinal)
{
"Windows",
"Dns",
"Ios",
"Statistics",
};

// Splits Pascal or camel text into words. Handles acronym runs ("OS" in "MacOSDmgApp"),
Expand Down Expand Up @@ -85,7 +91,7 @@ public static string SingularizeWord(string word)
if (EndsWithSibilantEs(word))
return word[..^2]; // Businesses -> Business, Mailboxes -> Mailbox
if (word.EndsWith("ss", StringComparison.Ordinal) || word.EndsWith("us", StringComparison.Ordinal) || word.EndsWith("is", StringComparison.Ordinal))
return word; // Access, Status, Analysis stay put
return word; // Access -> Access, Status -> Status, Analysis -> Analysis
if (word.EndsWith('s'))
return word[..^1]; // Messages -> Message, Plans -> Plan
return word;
Expand Down
Loading
Loading