Skip to content

Add WrapperGenerator: a standalone cmdlet generator that reproduces published MS Graph cmdlet names - #3682

Open
Joywambui-maina wants to merge 2 commits into
powershell-v3from
feature/wrapper-generator-clean
Open

Add WrapperGenerator: a standalone cmdlet generator that reproduces published MS Graph cmdlet names#3682
Joywambui-maina wants to merge 2 commits into
powershell-v3from
feature/wrapper-generator-clean

Conversation

@Joywambui-maina

@Joywambui-maina Joywambui-maina commented Jul 29, 2026

Copy link
Copy Markdown

Changes Proposed

  • Adds tools/WrapperGenerator, a standalone .NET CLI that generates C# cmdlet
    classes (e.g. Get-MgUserMessage) from Graph's OpenAPI description
  • Builds cmdlet nouns from URL paths with per-word singularization instead of
    operationIds, for deterministic naming
  • Reproduces published SDK hand-tuned names as checked-in data, with entries citing their AutoRest directive sources
  • Emits paired list/item GETs as one public dispatcher cmdlet with List/Get
    parameter sets forwarding to internal cmdlets
  • Includes 69 unit tests validated against published Microsoft.Graph cmdlet
    names from MgCommandMetadata.json
  • Adds tools/Compare-WrapperCmdletNames.ps1 for parity validation against the command metadata inventory
  • Documents the naming algorithm rules with shipping cmdlets as evidence, plus identified gaps
  • Updates .azure-pipelines/common-templates/install-tools.yml to install the
    .NET 10 SDK so CI can build the net10.0 generator projects

Why

Customer scripts depend on the exact cmdlet names the SDK ships — Get-MgUserMessage, not Get-MgUsersMessages. When nouns are derived from operationIds, generated names leak whatever plurality the spec author chose, and collisions have occurred. Path-based deterministic naming with singularization rules keeps name parity stable.

Validation

  • All 69 unit tests pass (dotnet test on net10.0).
  • Generated modules were checked against the published cmdlet inventory
    (MgCommandMetadata.json) using tools/Compare-WrapperCmdletNames.ps1: every generated cmdlet whose API call exists in the published SDK matches the published name exactly (e.g. BackupRestore 183/186, Bookings 129/131). The handful of non-matches are endpoints with no published cmdlet to compare against — not naming mismatches.
  • The generated C# compiles alongside the Kiota-generated client; module
    wiring is documented as future work.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new standalone .NET CLI generator (tools/WrapperGenerator) intended to reproduce the published Microsoft.Graph PowerShell cmdlet names deterministically by deriving nouns from URL paths (with per-word singularization), alongside tests and tooling to validate parity against MgCommandMetadata.json. It also updates CI tooling to install a newer .NET SDK required to build the new net10.0 projects.

Changes:

  • Introduces tools/WrapperGenerator (OpenAPI reader + naming + emission) to generate wrapper cmdlet C# sources and a lock file.
  • Adds a dedicated test project covering naming/singularization/schema-property extraction and generation regressions.
  • Adds a parity gate script (tools/Compare-WrapperCmdletNames.ps1) and updates pipeline tooling to install the needed .NET SDK.

Reviewed changes

Copilot reviewed 21 out of 22 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tools/WrapperGenerator/WrapperGenerator.csproj New net10.0 CLI project and package dependencies for OpenAPI reading/logging.
tools/WrapperGenerator/Program.cs CLI entry point: arg parsing, spec loading, include-path filtering, run + lockfile write.
tools/WrapperGenerator/PowerShellWrapperGenerationService.cs Orchestrates operation selection, GET pairing, and writing emitted .g.cs files.
tools/WrapperGenerator/CmdletNaming.cs Implements verb/noun resolution and Kiota builder-expression construction.
tools/WrapperGenerator/Singularizer.cs Path-segment per-word singularization rules to match published cmdlet nouns.
tools/WrapperGenerator/NamingOverrides.cs Data-driven overrides/suppressions to mirror published SDK AutoRest directives.
tools/WrapperGenerator/IncludePathFilter.cs Filters OpenAPI paths/operations based on --include-path patterns.
tools/WrapperGenerator/CmdletEmitter.cs Emits cmdlet C# templates (GET/list, dispatcher, new/update/remove, shared auth helpers).
tools/WrapperGenerator/SchemaProperties.cs Extracts shallow primitive body properties for New/Update parameter flattening.
tools/WrapperGenerator/OperationInfo.cs Operation metadata record used during naming/emission decisions.
tools/WrapperGenerator/EmitContext.cs Carries emitter namespace settings (client namespace + cmdlet namespace).
tools/WrapperGenerator/GeneratorConfig.cs Records generator run configuration (client namespace, output path).
tools/WrapperGenerator/GeneratorExtensions.cs Shared string casing helpers + OpenAPI $ref ID extraction.
tools/WrapperGenerator/README.md Documents naming algorithm, output shapes, build/run/test steps, and known gaps.
tools/WrapperGenerator.Tests/WrapperGenerator.Tests.csproj New net10.0 test project referencing WrapperGenerator.
tools/WrapperGenerator.Tests/NamingTests.cs Golden naming/singularization/builder-expression tests against published cmdlet expectations.
tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs Tests property extraction and passwordProfile detection behavior.
tools/WrapperGenerator.Tests/GenerationServiceRegressionTests.cs Regression tests ensuring generator skips unsupported shapes without throwing.
tools/WrapperGenerator.Tests/EmitterTests.cs Tests emitter escaping of spec-derived string literals (quotes).
tools/Compare-WrapperCmdletNames.ps1 New parity gate: reconstructs Method+URI from generated source and joins to oracle inventory.
.azure-pipelines/common-templates/install-tools.yml Installs an additional .NET SDK to support building net10.0 tooling.
.gitignore Ignores sweep_results.json.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

null,
MyInvocation.BoundParameters, internalCmdletName);
}
{{CatchBlock(TargetId(itemNaming))}}
Comment on lines +156 to +157
$cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"([^"]+)"'
$callChainPattern = 'client\.([A-Za-z0-9_.\[\]]+)\.(Get|Post|Patch|Put|Delete)Async\('
Comment on lines +185 to +187
$verb = $attrMatch.Groups[1].Value
$generatedNoun = $attrMatch.Groups[2].Value
$publishedNoun = $generatedNoun -replace '_(List|Get)$', ''
Comment on lines +99 to +106
foreach ($token in ($BuilderExpression -split '\.')) {
if ($token -notmatch '^([A-Za-z0-9]+)(\[([A-Za-z0-9]+)\])?$') {
return $null
}
$prop = $Matches[1]
$segments += ($prop.Substring(0, 1).ToLowerInvariant() + $prop.Substring(1))
if ($Matches[3]) { $segments += '{param}' }
}

## The one subtle part: list + item GET become one cmdlet

Graph has two GETs for a resource — the collection (`GET …/messages`) and a single item (`GET …/messages/{message-id}`) — but the published SDK exposes **one** cmdlet, `Get-MgUserMessage`,that does both: no `-MessageId` lists them, a `-MessageId` fetches one.
@Joywambui-maina

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Microsoft"

@peombwa peombwa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this - the naming/singularization engine looks good to me for a first pass, and pinning tests to real published cmdlet names instead of arbitrary expectations was the right call. I'm yet to live run the branch, but I'll more comments later.

Please see my comments below for areas to address in the meantime.

// VerbsData, the rest in VerbsCommon.
private static readonly Dictionary<string, (string VerbsClass, string VerbName)> VerbMap = new(StringComparer.OrdinalIgnoreCase)
{
["get"] = ("VerbsCommon", "Get"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use strongly-typed values here instead of raw strings for both the HTTP method key and the cmdlet verb name - e.g. System.Net.Http.HttpMethod for the key and a PSVerb/enum-like wrapper (or at least a constant) for the verb, rather than comparing/storing "get", "Get", etc. as bare strings.

// plurality the spec author chose, while the published SDK names follow the path:
// GET /users/{id}/messages is Get-MgUserMessage. The few hand-tuned exceptions the
// published SDK carries are mirrored as data in NamingOverrides, never as code here.
var noun = "Mg" + NamingOverrides.ApplyNounOverrides(operation.HttpMethod, operation.Path, BuildNounFromPath(operation.Path));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move the hard-coded "Mg" prefix into a constants class rather than inlining it here - we'll want this to be a user-configurable value later, and centralizing it now avoids a scattered find/replace.

// get the same noun on purpose. PowerShellWrapperGenerationService pairs them into one
// public Get-MgX dispatcher cmdlet; the two real implementations get suffixed names via
// WithSuffix below.
var className = $"{verb.VerbName}{noun}Command";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the generated class name get a Command suffix? If it is needed, please add a comment explaining the convention (e.g. is this to avoid a name clash with the cmdlet's own noun/verb, or just a project convention?), since it isn't obvious from the surrounding code.

// /domains/{id}/domainNameReferences -> DomainNameReference (the shared word "Domain"
// appears once, matching Get-MgDomainNameReference)
// An OData cast segment like graph.user becomes AsUser, matching Get-MgGroupOwnerAsUser.
// Cast type names are already singular, so they skip the singularizer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states cast type names are "already singular" - that's not guaranteed for every possible cast segment. Let's harden the code to singularize cast names in all cases rather than relying on that assumption.

Comment on lines +86 to +95
var castType = segment.StartsWith("microsoft.graph.", StringComparison.OrdinalIgnoreCase)
? segment["microsoft.graph.".Length..]
: segment.StartsWith("graph.", StringComparison.OrdinalIgnoreCase)
? segment["graph.".Length..]
: null;
if (castType is not null)
{
parts.Add("As" + castType.ToFirstCharacterUpperCase());
continue;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's extract this block into a dedicated helper method (e.g. TryBuildCastSegmentNoun) so the cast-segment logic is separated from the general segment-walking loop and can be unit tested in isolation.

// stray dot ("Graph.user") — invalid C# and not a real builder member. Non-cast segments
// have no dots and are unchanged. NOTE: cast endpoints are not generated end to end yet
// (tracked follow-up), so this keeps the expression a valid identifier chain until then.
private static string ToBuilderMemberName(string segment) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method's logic only really applies to OData cast segments - the name should reflect that (e.g. ToCastAwareBuilderMemberName or similar) so it's self-documenting instead of reading as a generic segment-to-member converter.

return true;
}

private static string ResolveReferenceId(IOpenApiSchema schema) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveReferenceId is dead code and is never called anywhere AFICT. Please remove it.

Comment thread .gitignore
project.lock.json
project.fragment.lock.json
artifacts/
sweep_results.json

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orphaned .gitignore entry. No file produces in this diff.

@@ -5,7 +5,12 @@ steps:
- task: UseDotNet@2
displayName: Use .NET SDK

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
displayName: Use .NET SDK
displayName: Use .NET 10 SDK

version: 10.x

- task: UseDotNet@2
displayName: Use .NET SDK

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
displayName: Use .NET SDK
displayName: Use .NET 8 SDK

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants