From 3f91463c343d2d22c55b66529a8d81d58626ac89 Mon Sep 17 00:00:00 2001 From: Microsoft Graph DevX Tooling Date: Wed, 30 Sep 2026 17:37:30 -0700 Subject: [PATCH] Add wrapper directive migration framework Pilot module-level directive mappings with Compliance and add read-only inventory tooling for measured v1.0 and beta migration planning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Compliance/Compliance.md | 10 + .../Compliance_cmdletConfigurations.cs | 25 +- .../Compliance_directiveMigrationMap.json | 57 +++ ...Get-WrapperDirectiveMigrationInventory.ps1 | 363 ++++++++++++++++++ tools/README.md | 2 + ...est-WrapperDirectiveMigrationInventory.ps1 | 76 ++++ tools/WrapperGenerator.Tests/NamingTests.cs | 15 + tools/WrapperGenerator/README.md | 34 ++ 8 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 src/Compliance/wrapper/Compliance_directiveMigrationMap.json create mode 100644 tools/Get-WrapperDirectiveMigrationInventory.ps1 create mode 100644 tools/Test-WrapperDirectiveMigrationInventory.ps1 diff --git a/src/Compliance/Compliance.md b/src/Compliance/Compliance.md index a633d47168c..7491505b461 100644 --- a/src/Compliance/Compliance.md +++ b/src/Compliance/Compliance.md @@ -17,10 +17,20 @@ require: ``` yaml directive: +# Wrapper migration: PATCH and DELETE for the nested /dataSource navigation are +# suppressed in src/Compliance/wrapper/Compliance_cmdletConfigurations.cs and +# mapped in the adjacent Compliance_directiveMigrationMap.json. NamingTests pins +# the behavior. Keep this AutoRest directive active until the service-module +# pipeline is retired. - where: subject: (^ComplianceEdiscoveryCaseNoncustodialDataSource$) variant: ^Update1$|^UpdateExpanded1$|^UpdateViaIdentity1$|^UpdateViaIdentityExpanded1$|^Delete1$|^DeleteViaIdentity1$ remove: true +# Wrapper migration: the wrapper's structural path naming already emits the +# appended DataSource noun for GET .../noncustodialDataSources/{id}/dataSource; +# the adjacent Compliance_directiveMigrationMap.json records the profile evidence +# and NamingTests pins the behavior. Keep this AutoRest rename active until the +# service-module pipeline is retired. - where: subject: (^ComplianceEdiscoveryCaseNoncustodialDataSource$) variant: ^Get1$|^GetViaIdentity1$ diff --git a/src/Compliance/wrapper/Compliance_cmdletConfigurations.cs b/src/Compliance/wrapper/Compliance_cmdletConfigurations.cs index 8d8d726c021..06534b61660 100644 --- a/src/Compliance/wrapper/Compliance_cmdletConfigurations.cs +++ b/src/Compliance/wrapper/Compliance_cmdletConfigurations.cs @@ -1,3 +1,26 @@ using System.Collections.Generic; +using System.Net.Http; + namespace WrapperGenerator; -public static partial class CmdletConfigurator { private static readonly List ComplianceCmdletConfigurations = []; } + +public static partial class CmdletConfigurator +{ + private static readonly List ComplianceCmdletConfigurations = + [ + // Compliance.md removes the Update1/Delete1 variants for + // ComplianceEdiscoveryCaseNoncustodialDataSource. Those variants are the nested + // /dataSource navigation operations; the parent noncustodialDataSource item remains. + new(OverrideKind.SuppressOperation, HttpMethod.Patch, + "/compliance/ediscovery/cases/{}/noncustodialdatasources/{}/datasource", + Match: PathMatch.Exact, Value: null, + Reason: "Compliance.md removes Update1 for ComplianceEdiscoveryCaseNoncustodialDataSource"), + new(OverrideKind.SuppressOperation, HttpMethod.Delete, + "/compliance/ediscovery/cases/{}/noncustodialdatasources/{}/datasource", + Match: PathMatch.Exact, Value: null, + Reason: "Compliance.md removes Delete1 for ComplianceEdiscoveryCaseNoncustodialDataSource"), + + // Compliance.md renames the corresponding Get1 variant by appending DataSource. + // The wrapper's path-based naming already emits that published noun, so no override + // entry is needed; NamingTests pins it beside the suppressions above. + ]; +} diff --git a/src/Compliance/wrapper/Compliance_directiveMigrationMap.json b/src/Compliance/wrapper/Compliance_directiveMigrationMap.json new file mode 100644 index 00000000000..f6c05f0899b --- /dev/null +++ b/src/Compliance/wrapper/Compliance_directiveMigrationMap.json @@ -0,0 +1,57 @@ +{ + "mappings": [ + { + "module": "Compliance", + "directiveIndex": 1, + "classification": "CmdletSuppression", + "sourceContains": "variant: ^Update1$|^UpdateExpanded1$|^UpdateViaIdentity1$|^UpdateViaIdentityExpanded1$|^Delete1$|^DeleteViaIdentity1$", + "implementation": "CmdletConfigurator", + "notes": "Suppress nested dataSource PATCH and DELETE; keep the parent noncustodialDataSource item operations.", + "profiles": { + "v1.0": { + "absenceExpected": true, + "operations": [] + }, + "beta": { + "absenceExpected": false, + "operations": [ + { + "method": "PATCH", + "path": "/compliance/ediscovery/cases/{case-id}/noncustodialDataSources/{noncustodialDataSource-id}/dataSource", + "operationId": "compliance.ediscovery.cases.noncustodialDataSources.UpdateDataSource" + }, + { + "method": "DELETE", + "path": "/compliance/ediscovery/cases/{case-id}/noncustodialDataSources/{noncustodialDataSource-id}/dataSource", + "operationId": "compliance.ediscovery.cases.noncustodialDataSources.DeleteDataSource" + } + ] + } + } + }, + { + "module": "Compliance", + "directiveIndex": 2, + "classification": "SubjectRename", + "sourceContains": "variant: ^Get1$|^GetViaIdentity1$", + "implementation": "StructuralNaming", + "notes": "The wrapper derives the appended DataSource noun directly from the nested navigation path; NamingTests pins the result.", + "profiles": { + "v1.0": { + "absenceExpected": true, + "operations": [] + }, + "beta": { + "absenceExpected": false, + "operations": [ + { + "method": "GET", + "path": "/compliance/ediscovery/cases/{case-id}/noncustodialDataSources/{noncustodialDataSource-id}/dataSource", + "operationId": "compliance.ediscovery.cases.noncustodialDataSources.GetDataSource" + } + ] + } + } + } + ] +} diff --git a/tools/Get-WrapperDirectiveMigrationInventory.ps1 b/tools/Get-WrapperDirectiveMigrationInventory.ps1 new file mode 100644 index 00000000000..661b9677f79 --- /dev/null +++ b/tools/Get-WrapperDirectiveMigrationInventory.ps1 @@ -0,0 +1,363 @@ +<# +.SYNOPSIS +Inventories module AutoRest directives for migration to WrapperGenerator configuration. + +.DESCRIPTION +Reads src//.md and classifies each directive group. Operation-ID suppressions +are resolved directly against the v1.0 and beta Kiota-compatible OpenAPI documents. AutoRest +subject/variant selectors cannot be translated safely from text alone, so reviewed mappings +live beside each module configuration in src//wrapper/_directiveMigrationMap.json. + +The command is read-only unless -OutputPath is supplied. It never edits module configuration +or markdown files. + +.PARAMETER Module +One or more module names. Omit to inventory every module with src//.md. + +.PARAMETER MappingPath +One or more reviewed mapping ledgers. Omit to discover the selected modules' ledgers. This +override is primarily exposed so the validation test can exercise failure cases. + +.PARAMETER OutputPath +Optional .json or .csv output path. + +.PARAMETER AsJson +Write JSON to the pipeline instead of inventory objects. + +.PARAMETER FailOnUnresolved +Exit with an error when any non-deferred directive remains unresolved. +#> +[CmdletBinding()] +param( + [string[]]$Module = @(), + [string[]]$MappingPath = @(), + [string]$OutputPath, + [switch]$AsJson, + [switch]$FailOnUnresolved +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$srcRoot = Join-Path $repoRoot 'src' +$specRoot = Join-Path $repoRoot 'openApiDocs_KiotaCompat' + +function Get-DirectiveClassification { + param([Parameter(Mandatory)][string]$Text) + + if ($Text -match '(?m)^\s+parameter-name:' -or + $Text -match '(?m)^\s+alias:' -or + $Text -match '(?m)^\s+transform:') { + return 'DeferredParameter' + } + if ($Text -match '(?m)^\s*-\s*where-operation-id:' -or + $Text -match '(?m)^\s+remove:\s*\$') { + return 'DeferredSchema' + } + if ($Text -match '(?m)^\s*-\s*remove-path-by-operation:') { + return 'OperationIdSuppression' + } + $removes = $Text -match '(?m)^\s+remove:\s*true\s*$' + $setsSubject = $Text -match '(?ms)^\s+set:\s*$.*?^\s+subject:' + $setsVerb = $Text -match '(?ms)^\s+set:\s*$.*?^\s+verb:' + if ($removes) { return 'CmdletSuppression' } + if ($setsSubject -and $setsVerb) { return 'SubjectAndVerbRename' } + if ($setsVerb) { return 'VerbRename' } + if ($setsSubject) { return 'SubjectRename' } + return 'Unclassified' +} + +function Get-ModuleDirectives { + param([Parameter(Mandatory)][string]$ModuleName) + + $path = Join-Path (Join-Path $srcRoot $ModuleName) "$ModuleName.md" + if (-not (Test-Path $path)) { throw "Module directive file not found: $path" } + $lines = @(Get-Content $path) + $directiveLine = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^\s*directive:\s*$') { $directiveLine = $i; break } + } + if ($directiveLine -lt 0) { return @() } + + $entries = [System.Collections.Generic.List[object]]::new() + $current = [System.Collections.Generic.List[string]]::new() + $currentLine = 0 + $index = 0 + for ($i = $directiveLine + 1; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match '^```\s*$') { break } + if ($lines[$i] -match '^\s*#') { continue } + if ($lines[$i] -match '^\s{2}-\s+') { + if ($current.Count -gt 0) { + $index++ + $text = $current -join [Environment]::NewLine + $entries.Add([pscustomobject]@{ + Module = $ModuleName + DirectiveIndex = $index + DirectiveLine = $currentLine + Classification = Get-DirectiveClassification $text + Text = $text + }) + $current.Clear() + } + $currentLine = $i + 1 + } + if ($currentLine -gt 0) { $current.Add($lines[$i]) } + } + if ($current.Count -gt 0) { + $index++ + $text = $current -join [Environment]::NewLine + $entries.Add([pscustomobject]@{ + Module = $ModuleName + DirectiveIndex = $index + DirectiveLine = $currentLine + Classification = Get-DirectiveClassification $text + Text = $text + }) + } + return $entries +} + +function Get-SpecOperations { + param( + [Parameter(Mandatory)][string]$ModuleName, + [Parameter(Mandatory)][string]$Profile + ) + + $path = Join-Path (Join-Path $specRoot $Profile) "$ModuleName.yml" + if (-not (Test-Path $path)) { return @() } + $operations = [System.Collections.Generic.List[object]]::new() + $currentPath = $null + $currentMethod = $null + foreach ($line in Get-Content $path) { + if ($line -match "^\s{2}['`"]?(?/[^'`"]+)['`"]?:\s*$") { + $currentPath = $Matches.path + $currentMethod = $null + continue + } + if ($currentPath -and $line -match '^\s{4}(?get|post|put|patch|delete|head|options|trace):\s*$') { + $currentMethod = $Matches.method.ToUpperInvariant() + continue + } + if ($currentPath -and $currentMethod -and $line -match '^\s{6}operationId:\s*(?.+?)\s*$') { + $operations.Add([pscustomobject]@{ + Method = $currentMethod + Path = $currentPath + OperationId = $Matches.id.Trim("'", '"') + }) + $currentMethod = $null + } + } + return $operations +} + +function Format-Operations { + param([object[]]$Operations) + return @($Operations | Sort-Object Path, Method | ForEach-Object { + "$($_.Method) $($_.Path) [$($_.OperationId)]" + }) +} + +if (-not $Module -or $Module.Count -eq 0) { + $Module = @(Get-ChildItem $srcRoot -Directory | Where-Object { + Test-Path (Join-Path $_.FullName "$($_.Name).md") + } | Select-Object -ExpandProperty Name | Sort-Object) +} +if (-not $MappingPath -or $MappingPath.Count -eq 0) { + $MappingPath = @($Module | ForEach-Object { + Join-Path (Join-Path (Join-Path $srcRoot $_) 'wrapper') "$($_)_directiveMigrationMap.json" + } | Where-Object { Test-Path $_ }) +} + +$mappingByKey = @{} +foreach ($ledgerPath in $MappingPath) { + if (-not (Test-Path $ledgerPath)) { throw "Directive mapping ledger not found: $ledgerPath" } + $mappingDocument = Get-Content $ledgerPath -Raw | ConvertFrom-Json + foreach ($mapping in @($mappingDocument.mappings)) { + $key = "$($mapping.module)|$($mapping.directiveIndex)" + if ($mappingByKey.ContainsKey($key)) { + throw "Duplicate directive mapping '$key' in $ledgerPath." + } + $mappingByKey[$key] = $mapping + } +} + +$rows = [System.Collections.Generic.List[object]]::new() +$usedMappings = [System.Collections.Generic.HashSet[string]]::new() +foreach ($moduleName in $Module) { + $configurationPath = Join-Path (Join-Path (Join-Path $srcRoot $moduleName) 'wrapper') "$($moduleName)_cmdletConfigurations.cs" + $configurationEntryCount = if (Test-Path $configurationPath) { + ([regex]::Matches((Get-Content $configurationPath -Raw), 'new\(OverrideKind\.')).Count + } + else { + 0 + } + $operationsByProfile = @{ + 'v1.0' = @(Get-SpecOperations -ModuleName $moduleName -Profile 'v1.0') + 'beta' = @(Get-SpecOperations -ModuleName $moduleName -Profile 'beta') + } + foreach ($directive in @(Get-ModuleDirectives $moduleName)) { + $key = "$moduleName|$($directive.DirectiveIndex)" + $mapping = $mappingByKey[$key] + $profileMatches = @{ 'v1.0' = @(); 'beta' = @() } + $implementation = '' + $notes = '' + $status = 'Unresolved' + + if ($directive.Classification -like 'Deferred*') { + $status = 'Deferred' + $implementation = 'AutoRest' + $notes = if ($directive.Classification -eq 'DeferredParameter') { + 'Parameter aliases and transforms are outside the cmdlet-surface migration.' + } + else { + 'Schema/response directives are outside the cmdlet-surface migration.' + } + } + elseif ($directive.Classification -eq 'OperationIdSuppression') { + $match = [regex]::Match($directive.Text, '(?m)remove-path-by-operation:\s*(?.+?)\s*$') + if (-not $match.Success) { + $status = 'Invalid' + $notes = 'Could not read remove-path-by-operation pattern.' + } + else { + $pattern = $match.Groups['pattern'].Value.Trim("'", '"') + try { + # AutoRest evaluates JavaScript regular expressions, where an identity escape + # such as \_ is accepted. .NET rejects it even though it means the same as _. + $dotNetPattern = $pattern -replace '\\_', '_' + $regex = [regex]::new($dotNetPattern) + foreach ($profile in @('v1.0', 'beta')) { + $profileMatches[$profile] = @($operationsByProfile[$profile] | + Where-Object { $regex.IsMatch($_.OperationId) }) + } + if ($profileMatches['v1.0'].Count + $profileMatches.beta.Count -gt 0) { + $status = 'Resolved' + $implementation = 'Candidate' + $notes = if ($dotNetPattern -ne $pattern) { + 'Resolved directly after normalizing JavaScript identity escapes for .NET.' + } + else { + 'Resolved directly from the operation-ID regular expression.' + } + } + } + catch { + $status = 'Unresolved' + $notes = "AutoRest operation-ID regular expression requires manual review: $($_.Exception.Message)" + } + } + } + elseif ($mapping) { + [void]$usedMappings.Add($key) + $implementation = "$($mapping.implementation)" + $notes = "$($mapping.notes)" + $missing = [System.Collections.Generic.List[string]]::new() + if ($mapping.classification -and + "$($mapping.classification)" -ne $directive.Classification) { + $missing.Add("classification changed from '$($mapping.classification)' to '$($directive.Classification)'") + } + if ($mapping.sourceContains -and + -not $directive.Text.Contains("$($mapping.sourceContains)", [StringComparison]::Ordinal)) { + $missing.Add("source directive no longer contains '$($mapping.sourceContains)'") + } + foreach ($profile in @('v1.0', 'beta')) { + $profileMap = $mapping.profiles.$profile + if ($null -eq $profileMap) { + $missing.Add("$profile mapping declaration") + continue + } + foreach ($expected in @($profileMap.operations)) { + $matches = @($operationsByProfile[$profile] | Where-Object { + $_.Method -eq "$($expected.method)".ToUpperInvariant() -and + $_.Path -eq "$($expected.path)" + }) + if ($matches.Count -ne 1) { + $missing.Add("$profile $($expected.method) $($expected.path) matched $($matches.Count) operations") + } + else { + if ($expected.operationId -and $matches[0].OperationId -ne "$($expected.operationId)") { + $missing.Add("$profile $($expected.method) $($expected.path) operationId changed from '$($expected.operationId)' to '$($matches[0].OperationId)'") + } + $profileMatches[$profile] += $matches[0] + } + } + if (@($profileMap.operations).Count -eq 0 -and -not $profileMap.absenceExpected) { + $missing.Add("$profile has no operations and is not marked absenceExpected") + } + } + if ($missing.Count -eq 0) { + $status = 'Mapped' + } + else { + $status = 'Invalid' + $notes = (@($notes) + @($missing)) -join ' ' + } + } + + $summary = ($directive.Text -replace '\s+', ' ').Trim() + $coverage = if ($status -eq 'Mapped') { + if ($implementation -eq 'StructuralNaming') { 'StructuralNaming' } else { 'ReviewedConfiguration' } + } + elseif ($configurationEntryCount -gt 0) { + 'ModuleHasUnreviewedConfiguration' + } + else { + 'None' + } + $rows.Add([pscustomobject]@{ + Module = $moduleName + DirectiveIndex = $directive.DirectiveIndex + DirectiveLine = $directive.DirectiveLine + Classification = $directive.Classification + Status = $status + Implementation = $implementation + ExistingCoverage = $coverage + ModuleConfigurationEntries = $configurationEntryCount + V1Operations = @(Format-Operations $profileMatches['v1.0']) + BetaOperations = @(Format-Operations $profileMatches.beta) + Notes = $notes + Directive = $summary + }) + } +} + +$unusedMappings = @($mappingByKey.GetEnumerator() | Where-Object { + $Module -contains $_.Value.module -and -not $usedMappings.Contains($_.Key) +}) +if ($unusedMappings.Count -gt 0) { + throw "Mapping ledger entries do not match a directive: $($unusedMappings.Key -join ', ')" +} + +$invalid = @($rows | Where-Object Status -eq 'Invalid') +if ($invalid.Count -gt 0) { + $details = $invalid | ForEach-Object { "$($_.Module) directive $($_.DirectiveIndex): $($_.Notes)" } + throw "Invalid directive migration mapping(s):`n$($details -join [Environment]::NewLine)" +} +if ($FailOnUnresolved) { + $unresolved = @($rows | Where-Object Status -eq 'Unresolved') + if ($unresolved.Count -gt 0) { + throw "$($unresolved.Count) non-deferred directive(s) remain unresolved." + } +} + +if ($OutputPath) { + $parent = Split-Path $OutputPath -Parent + if ($parent -and -not (Test-Path $parent)) { throw "Output directory does not exist: $parent" } + switch ([IO.Path]::GetExtension($OutputPath).ToLowerInvariant()) { + '.json' { $rows | ConvertTo-Json -Depth 6 | Set-Content $OutputPath } + '.csv' { + $rows | Select-Object Module, DirectiveIndex, DirectiveLine, Classification, Status, + Implementation, ExistingCoverage, ModuleConfigurationEntries, + @{ Name = 'V1Operations'; Expression = { $_.V1Operations -join '; ' } }, + @{ Name = 'BetaOperations'; Expression = { $_.BetaOperations -join '; ' } }, + Notes, Directive | + Export-Csv $OutputPath -NoTypeInformation + } + default { throw 'OutputPath must end in .json or .csv.' } + } +} +elseif ($AsJson) { + $rows | ConvertTo-Json -Depth 6 +} +else { + $rows +} diff --git a/tools/README.md b/tools/README.md index 53ba1fde822..020e95a4901 100644 --- a/tools/README.md +++ b/tools/README.md @@ -40,6 +40,8 @@ Everything else is a narrower tool for one of the scenarios below. | `Derive-ParityResolutions.ps1` | derives parity renames/suppressions for the whole surface | frozen input ledger, or `-CaptureInput` to build one | `data/parity-*.json`, ledger CSVs | | `Update-WrapperParityData.ps1` | orchestrates a clean parity refresh in an isolated copy | — | the four `data/parity-*` files | | `New-WrapperOutputManifest.ps1` | reviewable inventory of the committed output | the corpus | `docs/WrapperCmdlets-*.csv` | +| `Get-WrapperDirectiveMigrationInventory.ps1` | classifies AutoRest directives and maps reviewed v1.0/beta operations | module `.md` files, OpenAPI specs, reviewed mapping ledger | pipeline objects; optional JSON/CSV | +| `Test-WrapperDirectiveMigrationInventory.ps1` | validates the inventory tool, Compliance pilot mapping and read-only behavior | inventory tool + mapping ledger | console pass/fail | ## What depends on what diff --git a/tools/Test-WrapperDirectiveMigrationInventory.ps1 b/tools/Test-WrapperDirectiveMigrationInventory.ps1 new file mode 100644 index 00000000000..2064e1fd274 --- /dev/null +++ b/tools/Test-WrapperDirectiveMigrationInventory.ps1 @@ -0,0 +1,76 @@ +<# +.SYNOPSIS +Validates the directive migration inventory tool and its reviewed Compliance mapping. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$tool = Join-Path $PSScriptRoot 'Get-WrapperDirectiveMigrationInventory.ps1' +$mapping = Join-Path (Join-Path $PSScriptRoot '..\src\Compliance\wrapper') 'Compliance_directiveMigrationMap.json' +$before = & git -C (Resolve-Path (Join-Path $PSScriptRoot '..')).Path status --porcelain + +$compliance = @((& $tool -Module Compliance -MappingPath $mapping -AsJson | Out-String) | + ConvertFrom-Json) +if ($compliance.Count -ne 2) { throw "Expected 2 Compliance directives, found $($compliance.Count)." } +if (@($compliance | Where-Object Status -ne 'Mapped').Count -ne 0) { + throw 'Every Compliance directive must be mapped.' +} +$suppression = $compliance | Where-Object DirectiveIndex -eq 1 +if (@($suppression.BetaOperations | Where-Object { $_ -match '^PATCH ' }).Count -ne 1 -or + @($suppression.BetaOperations | Where-Object { $_ -match '^DELETE ' }).Count -ne 1) { + throw 'Compliance directive 1 must map to one beta PATCH and one beta DELETE.' +} +if ($null -ne $suppression.V1Operations -and @($suppression.V1Operations).Count -ne 0) { + throw 'Compliance directive 1 should have no v1.0 operations.' +} +$rename = $compliance | Where-Object DirectiveIndex -eq 2 +if ($rename.Implementation -ne 'StructuralNaming' -or + @($rename.BetaOperations | Where-Object { $_ -match '^GET ' }).Count -ne 1) { + throw 'Compliance directive 2 must map to one structurally named beta GET.' +} + +$applications = @((& $tool -Module Applications -MappingPath $mapping -AsJson | Out-String) | + ConvertFrom-Json) +if (@($applications | Where-Object Status -eq 'Deferred').Count -lt 1) { + throw 'Applications must expose at least one deferred parameter directive.' +} +if (@($applications | Where-Object Status -eq 'Unresolved').Count -lt 1) { + throw 'Applications must expose at least one unresolved cmdlet-surface directive.' +} +$calendar = @((& $tool -Module Calendar -MappingPath $mapping -AsJson | Out-String) | + ConvertFrom-Json) +$resolved = @($calendar | Where-Object Status -eq 'Resolved') +if ($resolved.Count -lt 1 -or @($resolved | Where-Object Implementation -ne 'Candidate').Count -gt 0) { + throw 'Automatically matched operation-ID directives must be candidates, not mapped migrations.' +} +$schemaDirective = @((& $tool -Module Identity.SignIns -MappingPath $mapping -AsJson | Out-String) | + ConvertFrom-Json) | Where-Object Classification -eq 'DeferredSchema' +if (@($schemaDirective).Count -ne 1 -or $schemaDirective.Status -ne 'Deferred') { + throw 'Schema/response directives must be classified as deferred, not unresolved.' +} + +$duplicateMapping = [IO.Path]::GetTempFileName() +try { + $document = Get-Content $mapping -Raw | ConvertFrom-Json + $document.mappings = @($document.mappings) + @($document.mappings[0]) + $document | ConvertTo-Json -Depth 10 | Set-Content $duplicateMapping + $failed = $false + try { + & $tool -Module Compliance -MappingPath $duplicateMapping | Out-Null + } + catch { + $failed = $_.Exception.Message -match 'Duplicate directive mapping' + } + if (-not $failed) { throw 'Duplicate mappings must fail visibly.' } +} +finally { + Remove-Item $duplicateMapping -Force -ErrorAction SilentlyContinue +} + +$after = & git -C (Resolve-Path (Join-Path $PSScriptRoot '..')).Path status --porcelain +if (($before -join "`n") -ne ($after -join "`n")) { + throw 'Inventory without -OutputPath must not modify the worktree.' +} + +Write-Host 'PASS: directive migration inventory and Compliance mapping validated.' diff --git a/tools/WrapperGenerator.Tests/NamingTests.cs b/tools/WrapperGenerator.Tests/NamingTests.cs index 446f9ecc7be..ee9b2204c08 100644 --- a/tools/WrapperGenerator.Tests/NamingTests.cs +++ b/tools/WrapperGenerator.Tests/NamingTests.cs @@ -121,6 +121,9 @@ private static CmdletNaming Resolve(string method, string path) => [InlineData("GET", "/sites/{site-id}/drive", "Get", "MgSiteDefaultDrive")] [InlineData("GET", "/groups/{group-id}/sites/{site-id}/drive", "Get", "MgGroupSiteDefaultDrive")] [InlineData("GET", "/users/{user-id}/calendar/events", "Get", "MgUserDefaultCalendarEvent")] + // Compliance.md renames the nested dataSource Get1 variant by appending DataSource. The + // wrapper derives the same noun directly from the route, so no curated rename is needed. + [InlineData("GET", "/compliance/ediscovery/cases/{case-id}/noncustodialDataSources/{noncustodialDataSource-id}/dataSource", "Get", "MgComplianceEdiscoveryCaseNoncustodialDataSourceDataSource")] // nested-collection GET renamed by the Groups.md directive (subject $1ByGroup) [InlineData("GET", "/groups/{group-id}/groupLifecyclePolicies", "Get", "MgGroupLifecyclePolicyByGroup")] // boundary word-overlap collapse (Get-MgDomainNameReference) @@ -205,6 +208,18 @@ public void SuppressesOperationsThePublishedSdkOmits() Assert.True(CmdletConfigurator.IsSuppressed(HttpMethod.Get, "/users/{user-id}/photos/{userProfilePhoto-id}")); Assert.False(CmdletConfigurator.IsSuppressed(HttpMethod.Get, "/users/{user-id}/photo")); + // Compliance.md removes only the Update1/Delete1 variants for the nested dataSource + // navigation. The renamed GET and the parent noncustodialDataSource item remain. + const string complianceDataSource = + "/compliance/ediscovery/cases/{case-id}/noncustodialDataSources/{noncustodialDataSource-id}/dataSource"; + const string complianceItem = + "/compliance/ediscovery/cases/{case-id}/noncustodialDataSources/{noncustodialDataSource-id}"; + Assert.True(CmdletConfigurator.IsSuppressed(HttpMethod.Patch, complianceDataSource)); + Assert.True(CmdletConfigurator.IsSuppressed(HttpMethod.Delete, complianceDataSource)); + Assert.False(CmdletConfigurator.IsSuppressed(HttpMethod.Get, complianceDataSource)); + Assert.False(CmdletConfigurator.IsSuppressed(HttpMethod.Patch, complianceItem)); + Assert.False(CmdletConfigurator.IsSuppressed(HttpMethod.Delete, complianceItem)); + // Suffix-matched suppressions apply under any root; siblings stay generated // (issue #3704: Info-wrapper navs ship nothing, their siblings ship). Assert.True(CmdletConfigurator.IsSuppressed(HttpMethod.Get, "/chats/{chat-id}/pinnedMessages/{pinnedChatMessageInfo-id}/message")); diff --git a/tools/WrapperGenerator/README.md b/tools/WrapperGenerator/README.md index c398ec0e590..276eb79e214 100644 --- a/tools/WrapperGenerator/README.md +++ b/tools/WrapperGenerator/README.md @@ -368,6 +368,40 @@ The generated cmdlets **are** compiled: `Build-WrapperModule.ps1` builds each mo ## Gaps / not done yet - **Only v1.0 output is committed.** The beta docs exist (`openApiDocs_KiotaCompat/beta`) but no beta output is generated or checked in yet; the layout already accommodates it at `src/{Module}/wrapper/beta/`. + +## Migrate module AutoRest directives + +Cmdlet removals and renames in `src//.md` are migrated one module at a time. +Inventory them without changing the worktree: + +```powershell +.\tools\Get-WrapperDirectiveMigrationInventory.ps1 -Module Compliance | + Format-Table Module,DirectiveIndex,Classification,Status,Implementation +.\tools\Test-WrapperDirectiveMigrationInventory.ps1 +``` + +`remove-path-by-operation` entries are resolved directly from operation IDs in both +Kiota-compatible specifications. AutoRest `where` selectors operate on generated subjects and +variants, so they require reviewed method/path evidence in +`src//wrapper/_directiveMigrationMap.json`; the tool deliberately reports an +unreviewed selector as `Unresolved` instead of guessing. An automatically matched operation-ID +directive is `Resolved`, not `Mapped`: it remains a candidate until its configuration and tests +have been reviewed. `ExistingCoverage` distinguishes reviewed configuration, structural naming, +and modules that contain unrelated or not-yet-reconciled entries. + +For each reviewed directive: + +1. Use exact method/path mappings for both v1.0 and beta. An absent profile must be marked + `absenceExpected`. +2. Add only configuration the wrapper naming rules cannot already produce. Pin structural naming + behavior in `NamingTests` instead of adding redundant overrides. +3. Keep the AutoRest directive active and annotate it with its wrapper disposition until the + service-module pipeline is retired. +4. Run the inventory validation and WrapperGenerator tests. Generate committed profile output + only in a separately reviewed profile-specific change. + +The inventory command is read-only unless `-OutputPath` is supplied. JSON and CSV are supported +for review artifacts; it never rewrites module markdown or configuration files. - **The runtime base exists; the polish around it does not.** `src/GraphWrapperRuntime/` ships `GraphClientCmdlet` with session-keyed request adapters over the shipped Authentication module's session, `-AccessToken` support, paging with a truncation warning and `-All`, delta-link validation against the session's service root, Ctrl+C cancellation, and one `GraphRequestFailed` error shape. Still open: per-cmdlet help and examples, a `SupportsShouldProcess` audit on destructive cmdlets, and an automated Windows PowerShell 5.1 gate leg (the dll targets it; the gates run on PowerShell 7 only). - **Body binding covers every shape reaching the classifier** — the omission oracle reports 0 failures across 2,235 body-writing cmdlets (24,003 members seen, 15,838 bound). That is a statement about the operations that generate, not about v1.0: see the coverage figure below. Classifications for shapes that do not occur (inline objects and enums, genuine unions, dictionaries, unresolvable references, unknown formats) are retained so a future corpus change is reported rather than silently mis-bound; [docs/edge-cases/body-binding-edge-cases.md](docs/edge-cases/body-binding-edge-cases.md) records each with its exit criteria. - **73.6% of v1.0 operations generate, deliberately.** Of 14,115 operations across the 38 specs (the DirectoryObjects re-slice removes the 16 publicKeyInfrastructure operations it double-declared with Identity.DirectoryManagement): 10,385 become cmdlets, 3,173 are suppressed because the published SDK ships no cmdlet for them (oracle-derived), and 557 are unsupported — 345 call segments on operations the spec does not class as an action or function, 125 routes that call a parameterized function before their final segment, 42 whose content response is neither a stream nor a resolvable entity, 24 with no wrapper emitter for the HTTP method, 13 OData parameter aliases, 6 unresolvable collection schemas, 2 missing request schemas. The three populations sum to 14,115 by construction. The rise from 61.3% is the OData `$`-segments — `$count`, `$ref` and `$value` were 2,304 unsupported operations and now have emitters of their own — plus PUT and the media/content downloads.