diff --git a/tools/Compare-WrapperCmdletNames.ps1 b/tools/Compare-WrapperCmdletNames.ps1 index fcf8c7a28c..3673773294 100644 --- a/tools/Compare-WrapperCmdletNames.ps1 +++ b/tools/Compare-WrapperCmdletNames.ps1 @@ -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 @@ -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 = @{ + # 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 @@ -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 @@ -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) { @@ -238,16 +257,25 @@ 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 @@ -255,10 +283,11 @@ foreach ($module in $modules | Sort-Object Name) { $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 diff --git a/tools/WrapperGenerator.Tests/NamingTests.cs b/tools/WrapperGenerator.Tests/NamingTests.cs index c466606d90..77ffe14470 100644 --- a/tools/WrapperGenerator.Tests/NamingTests.cs +++ b/tools/WrapperGenerator.Tests/NamingTests.cs @@ -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) @@ -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)); @@ -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")] @@ -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..GetAsync()). A property per fixed segment, an indexer per path parameter. diff --git a/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs b/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs index 5ee8eef3c3..7ac2b68961 100644 --- a/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs +++ b/tools/WrapperGenerator.Tests/SchemaPropertiesTests.cs @@ -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"), }, }; @@ -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] diff --git a/tools/WrapperGenerator/README.md b/tools/WrapperGenerator/README.md index aa348a69d0..73c467d0c3 100644 --- a/tools/WrapperGenerator/README.md +++ b/tools/WrapperGenerator/README.md @@ -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 @@ -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 # => 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 diff --git a/tools/WrapperGenerator/SchemaProperties.cs b/tools/WrapperGenerator/SchemaProperties.cs index 59269c8c9e..10d35e4d08 100644 --- a/tools/WrapperGenerator/SchemaProperties.cs +++ b/tools/WrapperGenerator/SchemaProperties.cs @@ -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 ExtractPrimitiveProperties(IOpenApiSchema schema) @@ -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); @@ -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", }; diff --git a/tools/WrapperGenerator/Singularizer.cs b/tools/WrapperGenerator/Singularizer.cs index c00957ecc8..767643c07e 100644 --- a/tools/WrapperGenerator/Singularizer.cs +++ b/tools/WrapperGenerator/Singularizer.cs @@ -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 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 Invariants = new(StringComparer.Ordinal) { "Windows", + "Dns", + "Ios", + "Statistics", }; // Splits Pascal or camel text into words. Handles acronym runs ("OS" in "MacOSDmgApp"), @@ -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; diff --git a/tools/WrapperGenerator/docs/edge-cases/naming-edge-cases.md b/tools/WrapperGenerator/docs/edge-cases/naming-edge-cases.md new file mode 100644 index 0000000000..0c70d0a06e --- /dev/null +++ b/tools/WrapperGenerator/docs/edge-cases/naming-edge-cases.md @@ -0,0 +1,138 @@ +# Naming edge cases + +This folder is the wrapper generator's edge-case catalog: one Markdown file per **class** of +issue, each entry written with the same fixed fields so the files stay cheap to maintain and +trivial to convert to JSON for automated processing. This file covers the first class: +**cmdlet-naming defects** — cases where the published Microsoft.Graph name is an artifact of +the previous generator (AutoRest) rather than the name the conventions would produce. + +Two policies govern the entries (agreed 2026-08-03/04, wrapper-generator review + sync): + +- **Obviously wrong published names are corrected, not reproduced.** The shipped SDK is the + baseline, not 100% ground truth. Each correction is a deliberate, documented break from + parity. +- **Corrected names ship without a back-compat alias for the old name.** Documenting the + change here and in the migration guide is the agreed mechanism; the generator does not emit + the wrong name in any form. + +## How to add an entry + +A correction lands as four pieces together: + +1. **Fix** — the naming rule change (or, as with Whois, confirmation that the existing rules + already produce the correct name). +2. **Pinned test** — a row in `AppliesDeliberateNameCorrections` (NamingTests.cs) so the + corrected name cannot regress silently. Parity-preserving edge cases go in the regular + pinned tests instead. +3. **Gate entry** — a row in `$deliberateCorrections` in `tools/Compare-WrapperCmdletNames.ps1` + mapping the shipped name to the corrected one, so the parity gate reports `[CORRECTED]` + instead of failing. +4. **Catalog entry** — a section below using the fixed field template. + +Entry template (keep the field names exact so the file converts cleanly): + +``` +## +- **Class:** +- **Status:** +- **Evidence:** +- **Decision:** +- **Migration impact:** +- **References:** +``` + +## Status summary + +| Case | Class | Status | +|---|---|---| +| `HostWhoi` → `HostWhois` | inflection-defect | corrected | +| operationId preposition truncation | operationid-truncation | structurally-avoided | +| `SkypeForBusiness` subject truncation | operationid-truncation | not-yet-reachable | +| `Cookies`/`Skus`/`Dns`/`Ios`/`Statistics` quirks | inflection-defect | reproduced-for-parity | + +## Whois truncated to Whoi on the host navigation + +- **Class:** inflection-defect +- **Status:** corrected +- **Evidence:** `GET /security/threatIntelligence/hosts/{host-id}/whois` shipped as + `Get-MgSecurityThreatIntelligenceHostWhoi` (v1.0 and beta): AutoRest's inflector treated the + trailing `whois` segment as a plural and stripped the `s`. The shipped SDK is inconsistent + with itself — the other 28 whois-family commands in MgCommandMetadata.json + (`.../whoisRecords`, `.../whoisHistoryRecords`, and their children) all keep **Whois** + intact, e.g. `Get-MgSecurityThreatIntelligenceWhoisRecord`. +- **Decision:** emit `Get-MgSecurityThreatIntelligenceHostWhois` / `Get-MgBetaSecurityThreatIntelligenceHostWhois`. + The singularizer's `is`-guard (the rule that keeps Access/Status/Analysis) already produces + `Whois`, so no rule change was needed — the corrected behavior is pinned rather than coded. +- **Migration impact:** scripts calling `Get-MgSecurityThreatIntelligenceHostWhoi` must add the + trailing `s`; no alias is emitted for the old name. Belongs in the migration guide when the + Security module is generated for real. +- **References:** pinned in `AppliesDeliberateNameCorrections` (NamingTests.cs); gate rows in + `$deliberateCorrections` (Compare-WrapperCmdletNames.ps1). + +## operationId preposition/linking-verb truncation + +- **Class:** operationid-truncation +- **Status:** structurally-avoided +- **Evidence:** AutoRest built cmdlet names from **operationIds** and truncated them at + prepositions and linking verbs, so ids like `...ByRef...` lost everything after the + preposition. The SDK worked around it with hand-written rename directives per affected + command. +- **Decision:** no mitigation needed for path-derived nouns — this generator never reads the + operationId; nouns come from URL path segments (CmdletNaming.cs), so the defect class cannot + occur there. Two watch items: (a) **OData actions/functions** (not yet generated) take their + names from an operationId-like segment (`microsoft.graph.assignLicense`, + `getSkypeForBusiness...`) — when that support lands, word-splitting must not treat + prepositions as truncation points; (b) path segments that legitimately contain prepositions + (`termsAndConditions`) are already pinned — the singularizer inflects per word and keeps the + `And` (`TermAndCondition`). +- **Migration impact:** none today. +- **References:** issue [microsoftgraph/msgraph-sdk-powershell#912](https://github.com/microsoftgraph/msgraph-sdk-powershell/issues/912), + PR [#915](https://github.com/microsoftgraph/msgraph-sdk-powershell/pull/915). + +## SkypeForBusiness subject names + +- **Class:** operationid-truncation +- **Status:** not-yet-reachable +- **Evidence:** historically AutoRest truncated subjects containing `SkypeForBusiness` at the + `For`. The shipped names are correct today + (`Get-MgReportSkypeForBusinessActivityUserDetail`, etc.), so there is nothing to correct — + but every affected endpoint is an OData function + (`/reports/getSkypeForBusinessActivityCounts(period='{period}')`), a shape this generator + does not emit yet. +- **Decision:** when function support is implemented, add pinned tests for the + `SkypeForBusiness` family so the `For` survives word-splitting. +- **Migration impact:** none. +- **References:** [Azure/autorest.powershell#795](https://github.com/Azure/autorest.powershell/issues/795). + +## Inflection quirks reproduced for parity + +- **Class:** inflection-defect +- **Status:** reproduced-for-parity +- **Evidence:** auditing every v1.0 GET in MgCommandMetadata.json against the singularizer + surfaced four words where shipped names disagree with naive inflection rules: `Cookies` → + `Cookie` (not `Cooky`), `Skus` → `Sku` (despite the `us`-guard), and `Dns`/`Ios` kept as-is. + A fifth, `Statistics`, came from cross-checking the DEVX API's Humanizer exception list: + the shipped SDK keeps it intact everywhere, including as an interior word + (`Get-MgSecurityCaseEdiscoveryCaseSearchLastEstimateStatisticsOperation`, + `Get-MgBetaUserActivityStatistics`), where the plain s-drop rule would have produced + `Statistic`. +- **Decision:** these shipped names are *reasonable*, just not what naive rules produce, so the + generator reproduces them via the irregulars/invariants tables in Singularizer.cs. The + README's rule table cites the proving cmdlet for each. +- **Migration impact:** none — these are parity-preserving. +- **References:** commit `a429b5999c`; Singularizer.cs `Irregulars`/`Invariants`; the DEVX + API's Humanizer vocabulary in `OpenAPIService/PowershellFormatter.cs` (private + `microsoftgraph/microsoft-graph-devx-api` repo) — its five entries are `drives→drive`, + `data`, `delta`, `quota` (Humanizer-specific mistakes this rule engine never makes) and + `statistics` (the one that applied here). + +## Watch list + +Cases spotted but deliberately not acted on yet, so they aren't lost: + +- **`usageRights` vs `rights` (beta-only):** the shipped SDK keeps `usageRights` plural + (`Get-MgBetaDeviceUsageRights` for `/devices/{id}/usageRights`) but singularizes bare + `rights` (`Get-MgBetaGroupSiteInformationProtectionSensitivityLabelRight` for + `.../sensitivityLabels/{id}/rights`). Our rules match the bare-`rights` case and would + diverge on `usageRights`. All affected paths are beta; resolve when the beta parity audit + runs.