Add WrapperGenerator: a standalone cmdlet generator that reproduces published MS Graph cmdlet names - #3682
Add WrapperGenerator: a standalone cmdlet generator that reproduces published MS Graph cmdlet names#3682Joywambui-maina wants to merge 2 commits into
Conversation
…h cmdlet-name parity checks and unit tests
There was a problem hiding this comment.
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))}} |
| $cmdletAttrPattern = '\[Cmdlet\(Verbs\w+\.(\w+),\s*"([^"]+)"' | ||
| $callChainPattern = 'client\.([A-Za-z0-9_.\[\]]+)\.(Get|Post|Patch|Put|Delete)Async\(' |
| $verb = $attrMatch.Groups[1].Value | ||
| $generatedNoun = $attrMatch.Groups[2].Value | ||
| $publishedNoun = $generatedNoun -replace '_(List|Get)$', '' |
| 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. |
|
@microsoft-github-policy-service agree company="Microsoft" |
peombwa
left a comment
There was a problem hiding this comment.
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"), |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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"; |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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) => |
There was a problem hiding this comment.
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) => |
There was a problem hiding this comment.
ResolveReferenceId is dead code and is never called anywhere AFICT. Please remove it.
| project.lock.json | ||
| project.fragment.lock.json | ||
| artifacts/ | ||
| sweep_results.json |
There was a problem hiding this comment.
Orphaned .gitignore entry. No file produces in this diff.
| @@ -5,7 +5,12 @@ steps: | |||
| - task: UseDotNet@2 | |||
| displayName: Use .NET SDK | |||
There was a problem hiding this comment.
nit:
| displayName: Use .NET SDK | |
| displayName: Use .NET 10 SDK |
| version: 10.x | ||
|
|
||
| - task: UseDotNet@2 | ||
| displayName: Use .NET SDK |
There was a problem hiding this comment.
nit:
| displayName: Use .NET SDK | |
| displayName: Use .NET 8 SDK |
Changes Proposed
tools/WrapperGenerator, a standalone .NET CLI that generates C# cmdletclasses (e.g.
Get-MgUserMessage) from Graph's OpenAPI descriptionoperationIds, for deterministic naming
List/Getparameter sets forwarding to internal cmdlets
names from
MgCommandMetadata.jsontools/Compare-WrapperCmdletNames.ps1for parity validation against the command metadata inventory.azure-pipelines/common-templates/install-tools.ymlto install the.NET 10 SDK so CI can build the
net10.0generator projectsWhy
Customer scripts depend on the exact cmdlet names the SDK ships —
Get-MgUserMessage, notGet-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
dotnet teston net10.0).(
MgCommandMetadata.json) usingtools/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.wiring is documented as future work.