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
80 changes: 63 additions & 17 deletions .github/skills/azure-sdk-mgmt-pr-review/Check-MgmtNamingRules.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
on types/members that are new or changed compared to the baseline will be reported.
This enables deterministic filtering without relying on LLM judgment.

.PARAMETER BaselineVersion
Released package version represented by BaselineApiFilePath. Included in compatibility
findings so reviewers can identify the authoritative GA contract that was compared.

.PARAMETER ExcludeRules
Array of rule IDs to skip (e.g., 'SUFFIX001', 'BOOL001').

Expand All @@ -50,6 +54,9 @@ param(
[Parameter(Mandatory = $false)]
[string]$BaselineApiFilePath,

[Parameter(Mandatory = $false)]
[string]$BaselineVersion,

[Parameter(Mandatory = $false)]
[string[]]$ExcludeRules = @(),

Expand Down Expand Up @@ -123,12 +130,12 @@ if (-not (Test-Path $ApiFilePath)) {
}

Write-Host "Scanning: $ApiFilePath" -ForegroundColor Cyan
$lines = Get-Content $ApiFilePath
$lines = @(Get-Content $ApiFilePath)
$totalLines = $lines.Count

# Load baseline API file for filtering (if provided)
$baselineLines = @{}
$baselineTypeKeys = @{}
$baselineLines = [System.Collections.Generic.Dictionary[string, bool]]::new([System.StringComparer]::Ordinal)
$baselineTypeKeys = [System.Collections.Generic.Dictionary[string, bool]]::new([System.StringComparer]::Ordinal)
if ($BaselineApiFilePath) {
if (-not (Test-Path $BaselineApiFilePath)) {
throw "Baseline API file not found: $BaselineApiFilePath"
Expand Down Expand Up @@ -342,21 +349,25 @@ function Get-ApiMethodInfos([string[]]$apiLines) {
Name = $Matches['name']
Type = $parameterType
IsOptional = $defaultSeparator -ge 0
Default = if ($defaultSeparator -ge 0) { $parameter.Substring($defaultSeparator + 1).Trim() } else { $null }
})
}

$key = "$namespace|$typeName|$memberName|$($parameterTypes -join ',')"
$methods[$key] = [pscustomobject]@{
Namespace = $namespace
TypeName = $typeName
MemberName = $memberName
Parameters = $parameters.ToArray()
Signature = "$memberName($parameterText)"
Line = $lineIndex + 1
}
}

return $methods
}


#endregion

#region --- Inventory mode (-ListNewTypes) ---
Expand Down Expand Up @@ -407,19 +418,23 @@ if ($ListNewTypes) {
#region --- Rule Checks ---

# =====================================================
# RULE: OPTPARAM - Preserve required/optional metadata
# RULE: PARAM/OPTPARAM - Preserve GA parameter compatibility
# =====================================================
# ApiCompat primarily protects binary compatibility. Changing whether a shipped
# parameter is optional can still break source compilation or introduce overload
# ambiguity, so compare every matching public method/constructor signature against
# the stable API baseline.
# ApiCompat primarily protects binary compatibility. Parameter names, positional
# ordering among same-typed parameters, and optional metadata can still break source
# callers. Compare against the released GA signature, then evaluate the complete
# current overload set before reporting optionality differences.
if ($BaselineApiFilePath -and
($ExcludeRules -notcontains 'OPTPARAM001' -or $ExcludeRules -notcontains 'OPTPARAM002')) {
($ExcludeRules -notcontains 'PARAMNAME001' -or
$ExcludeRules -notcontains 'PARAMORDER001' -or
$ExcludeRules -notcontains 'OPTPARAM001' -or
$ExcludeRules -notcontains 'OPTPARAM002')) {
$currentMethods = Get-ApiMethodInfos $lines
$baselineMethods = Get-ApiMethodInfos (Get-Content $BaselineApiFilePath)
$baselineLabel = if ($BaselineVersion) { "GA baseline $BaselineVersion" } else { 'GA baseline' }

foreach ($key in $currentMethods.Keys) {
if (-not $baselineMethods.ContainsKey($key)) {
foreach ($key in $baselineMethods.Keys) {
if (-not $currentMethods.ContainsKey($key)) {
continue
}

Expand All @@ -428,11 +443,18 @@ if ($BaselineApiFilePath -and
$parameterCount = [Math]::Min($currentMethod.Parameters.Count, $baselineMethod.Parameters.Count)
$optionalToRequired = [System.Collections.Generic.List[string]]::new()
$requiredToOptional = [System.Collections.Generic.List[string]]::new()
$renamedParameters = [System.Collections.Generic.List[string]]::new()
$baselineNames = @($baselineMethod.Parameters | ForEach-Object { $_.Name })
$currentNames = @($currentMethod.Parameters | ForEach-Object { $_.Name })

for ($parameterIndex = 0; $parameterIndex -lt $parameterCount; $parameterIndex++) {
$currentParameter = $currentMethod.Parameters[$parameterIndex]
$baselineParameter = $baselineMethod.Parameters[$parameterIndex]

if ($baselineParameter.Name -cne $currentParameter.Name) {
$renamedParameters.Add("'$($baselineParameter.Name)' to '$($currentParameter.Name)'")
}

if ($baselineParameter.IsOptional -and
-not $currentParameter.IsOptional -and
$ExcludeRules -notcontains 'OPTPARAM001') {
Expand All @@ -445,24 +467,44 @@ if ($BaselineApiFilePath -and
}
}

if ($renamedParameters.Count -gt 0) {
$sameNamesDifferentOrder = $baselineNames.Count -eq $currentNames.Count -and
@(Compare-Object ($baselineNames | Sort-Object -CaseSensitive) ($currentNames | Sort-Object -CaseSensitive) -CaseSensitive).Count -eq 0
$ruleId = if ($sameNamesDifferentOrder) { 'PARAMORDER001' } else { 'PARAMNAME001' }
if ($ExcludeRules -notcontains $ruleId) {
$changeDescription = if ($sameNamesDifferentOrder) {
"Parameter order changed from '$($baselineNames -join ', ')' to '$($currentNames -join ', ')'."
} else {
"Parameter name changes: $($renamedParameters -join ', ')."
}
$violations.Add([NamingViolation]::new(
$ruleId, 'Error', 'Source Compatibility',
$currentMethod.TypeName, $currentMethod.MemberName,
"$changeDescription Named or positional callers compiled against $baselineLabel can bind differently or fail. Baseline signature: $($baselineMethod.Signature). Current signature: $($currentMethod.Signature).",
"Preserve the exact parameter names and ordering from $baselineLabel. Investigate forwarding code separately for runtime-semantic correctness.",
$currentMethod.Line
))
}
}

if ($optionalToRequired.Count -gt 0) {
$parameterNames = ($optionalToRequired | ForEach-Object { "'$_'" }) -join ', '
$violations.Add([NamingViolation]::new(
'OPTPARAM001', 'Error', 'Source Compatibility',
'OPTPARAM001', 'Warning', 'Source Compatibility Candidate',
$currentMethod.TypeName, $currentMethod.MemberName,
"Parameter(s) $parameterNames changed from optional to required on '$($currentMethod.MemberName)'. ApiCompat does not report this binary-compatible source break.",
"Restore the optional defaults from the stable API baseline.",
"Parameter(s) $parameterNames changed from optional to required relative to $baselineLabel. This textual difference is not a blocking finding until the complete overload sets are compiled with representative GA calls. Baseline signature: $($baselineMethod.Signature). Current signature: $($currentMethod.Signature).",
"Compile positional, named, omitted, and default-literal calls against both GA and current overload sets; block only for a demonstrated source break.",
$currentMethod.Line
))
}

if ($requiredToOptional.Count -gt 0) {
$parameterNames = ($requiredToOptional | ForEach-Object { "'$_'" }) -join ', '
$violations.Add([NamingViolation]::new(
'OPTPARAM002', 'Error', 'Source Compatibility',
'OPTPARAM002', 'Warning', 'Source Compatibility Candidate',
$currentMethod.TypeName, $currentMethod.MemberName,
"Parameter(s) $parameterNames changed from required to optional on '$($currentMethod.MemberName)'. This can introduce overload ambiguity that ApiCompat does not detect.",
"Remove the default values and preserve the stable API signature.",
"Parameter(s) $parameterNames changed from required to optional relative to $baselineLabel. This textual difference is not a blocking finding until the complete overload sets are compiled with representative GA calls. Baseline signature: $($baselineMethod.Signature). Current signature: $($currentMethod.Signature).",
"Compile positional, named, omitted, and default-literal calls against both GA and current overload sets; block only for demonstrated ambiguity or changed binding.",
$currentMethod.Line
))
}
Expand Down Expand Up @@ -1042,6 +1084,10 @@ for ($i = 0; $i -lt $totalLines; $i++) {
if ($BaselineApiFilePath -and $baselineLines.Count -gt 0) {
$filteredViolations = [System.Collections.Generic.List[NamingViolation]]::new()
foreach ($v in $violations) {
if ($v.Category -like 'Source Compatibility*') {
$filteredViolations.Add($v)
continue
}
# For type-level violations, check if the type declaration line exists in baseline
# For member-level violations, check if the member line exists in baseline
$violationLine = $lines[$v.Line - 1].Trim()
Expand Down
212 changes: 212 additions & 0 deletions .github/skills/azure-sdk-mgmt-pr-review/Export-GaApiBaseline.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Exports the public API surface from a released NuGet assembly.

.DESCRIPTION
Downloads the exact package version configured by ApiCompatVersion and runs GenAPI
over the released DLL. Package code is never loaded or executed; only assembly
metadata is read. Use the output to verify disputed parameter signatures before
reporting a management SDK compatibility finding.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$PackageName,

[Parameter(Mandatory = $true)]
[string]$Version,

[Parameter(Mandatory = $true)]
[string]$OutputPath,

[string]$TargetFramework,

[string]$GenApiVersion = '5.0.0-beta.19552.1'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if ($PackageName -notmatch '^[A-Za-z0-9_.-]+$') {
throw "Invalid package name: $PackageName"
}
if ($Version -notmatch '^[0-9A-Za-z.+-]+$') {
throw "Invalid package version: $Version"
}
if ($TargetFramework -and $TargetFramework -notmatch '^[A-Za-z0-9.-]+$') {
throw "Invalid target framework: $TargetFramework"
}

$tempDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("ga-api-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tempDirectory | Out-Null
$azureSdkFeed = 'https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-net/nuget/v3/index.json'

try {
$packageId = $PackageName.ToLowerInvariant()
$packageVersion = $Version.ToLowerInvariant()
$packageArchive = Join-Path $tempDirectory "$packageId.$packageVersion.nupkg"
$packageDirectory = Join-Path $tempDirectory 'package'
$packageUrl = "https://api.nuget.org/v3-flatcontainer/$packageId/$packageVersion/$packageId.$packageVersion.nupkg"
Invoke-WebRequest -Uri $packageUrl -OutFile $packageArchive
[System.IO.Compression.ZipFile]::ExtractToDirectory($packageArchive, $packageDirectory)

$libDirectory = Join-Path $packageDirectory 'lib'
if (-not (Test-Path $libDirectory)) {
throw "Released package $PackageName $Version does not contain a lib directory."
}

if ($TargetFramework) {
$frameworkDirectory = Join-Path $libDirectory $TargetFramework
if (-not (Test-Path $frameworkDirectory)) {
throw "Released package $PackageName $Version does not contain lib/$TargetFramework."
}
} else {
$availableFrameworks = @(Get-ChildItem -Path $libDirectory -Directory)
$preferredFrameworks = @('net10.0', 'net9.0', 'net8.0', 'netstandard2.1', 'netstandard2.0')
$frameworkDirectory = $null
foreach ($preferredFramework in $preferredFrameworks) {
$match = $availableFrameworks | Where-Object { $_.Name -eq $preferredFramework } | Select-Object -First 1
if ($match) {
$frameworkDirectory = $match.FullName
break
}
}
if (-not $frameworkDirectory) {
$frameworkDirectory = $availableFrameworks | Sort-Object Name | Select-Object -First 1 -ExpandProperty FullName
}
$TargetFramework = Split-Path $frameworkDirectory -Leaf
}

$assemblyPath = Join-Path $frameworkDirectory "$PackageName.dll"
if (-not (Test-Path $assemblyPath)) {
throw "Released assembly not found: $assemblyPath"
}

$genApiPackageVersion = $GenApiVersion.ToLowerInvariant()
$nugetPackages = if ($env:NUGET_PACKAGES) {
$env:NUGET_PACKAGES
} else {
Join-Path $HOME '.nuget/packages'
}
$genApiPackageDirectory = Join-Path $nugetPackages "microsoft.dotnet.genapi/$genApiPackageVersion"
if (-not (Test-Path $genApiPackageDirectory)) {
$restoreProject = Join-Path $tempDirectory 'restore.csproj'
@"
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup>
<ItemGroup><PackageReference Include="Microsoft.DotNet.GenAPI" Version="$GenApiVersion" /></ItemGroup>
</Project>
"@ | Set-Content -Path $restoreProject
# This repository's approved feed has NuGet.org configured as an upstream source.
& dotnet restore $restoreProject --source $azureSdkFeed
if ($LASTEXITCODE -ne 0) {
throw "Failed to restore Microsoft.DotNet.GenAPI $GenApiVersion."
}
}

$genApiDll = Get-ChildItem -Path (Join-Path $genApiPackageDirectory 'tools') -Recurse -Filter 'Microsoft.DotNet.GenAPI.dll' |
Where-Object { $_.FullName -match 'netcoreapp' } |
Sort-Object FullName -Descending |
Select-Object -First 1 -ExpandProperty FullName
if (-not $genApiDll) {
throw "Microsoft.DotNet.GenAPI.dll was not found in package version $GenApiVersion."
}

$dependencyProject = Join-Path $tempDirectory 'dependencies.csproj'
@"
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>$TargetFramework</TargetFramework></PropertyGroup>
<ItemGroup><PackageReference Include="$PackageName" Version="$Version" /></ItemGroup>
</Project>
"@ | Set-Content -Path $dependencyProject
& dotnet restore $dependencyProject --source $azureSdkFeed
if ($LASTEXITCODE -ne 0) {
throw "Failed to restore the dependency closure for $PackageName $Version."
}

$assetsPath = Join-Path $tempDirectory 'obj/project.assets.json'
$assets = Get-Content -Raw $assetsPath | ConvertFrom-Json -Depth 100
$packageFolder = $assets.packageFolders.PSObject.Properties |
Select-Object -First 1 -ExpandProperty Name
$framework = $assets.project.frameworks.PSObject.Properties |
Where-Object { $_.Value.targetAlias -eq $TargetFramework } |
Select-Object -First 1
$targetNames = [System.Collections.Generic.List[string]]::new()
$targetNames.Add($TargetFramework)
if ($framework) {
$targetNames.Add($framework.Name)
}
if ($TargetFramework -match '^netstandard(?<version>\d+\.\d+)$') {
$targetNames.Add(".NETStandard,Version=v$($Matches['version'])")
} elseif ($TargetFramework -match '^netcoreapp(?<version>\d+\.\d+)$') {
$targetNames.Add(".NETCoreApp,Version=v$($Matches['version'])")
}
$target = $assets.targets.PSObject.Properties |
Where-Object {
$targetProperty = $_
@($targetNames | Where-Object { $targetProperty.Name -like "$_*" }).Count -gt 0
} |
Select-Object -First 1 -ExpandProperty Value
if (-not $target) {
throw "Could not resolve restored target '$TargetFramework' in $assetsPath."
}
$libraryDirectories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($library in $target.PSObject.Properties) {
foreach ($assetGroupName in @('compile', 'runtime')) {
$assetGroup = $library.Value.$assetGroupName
if (-not $assetGroup) {
continue
}
foreach ($asset in $assetGroup.PSObject.Properties) {
if ($asset.Name.EndsWith('.dll')) {
$assetPath = Join-Path $packageFolder (Join-Path $library.Name.ToLowerInvariant() $asset.Name)
if (Test-Path $assetPath) {
$libraryDirectories.Add((Split-Path $assetPath -Parent)) | Out-Null
}
}
}
}
}
$dotnetExecutable = (Get-Command dotnet).Source
$dotnetTarget = (Get-Item $dotnetExecutable).Target
$dotnetRoot = if ($env:DOTNET_ROOT) {
$env:DOTNET_ROOT
} elseif ($dotnetTarget) {
Split-Path $dotnetTarget -Parent
} else {
Split-Path $dotnetExecutable -Parent
}
$packsDirectory = Join-Path $dotnetRoot 'packs'
foreach ($packDirectory in (Get-ChildItem -Path $packsDirectory -Directory -ErrorAction SilentlyContinue)) {
$referenceDirectory = Get-ChildItem -Path $packDirectory.FullName -Directory |
Sort-Object { [version]$_.Name } -Descending |
ForEach-Object { Join-Path $_.FullName "ref/$TargetFramework" } |
Where-Object { Test-Path $_ } |
Select-Object -First 1
if ($referenceDirectory) {
$libraryDirectories.Add($referenceDirectory) | Out-Null
}
}
$libraryDirectories.Add($PSHOME) | Out-Null

$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
$outputDirectory = Split-Path $resolvedOutputPath -Parent
New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null

$genApiOutput = & dotnet $genApiDll $assemblyPath --api-only --lib-path ($libraryDirectories -join ';') --out $resolvedOutputPath 2>&1
$genApiOutput | Write-Host
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $resolvedOutputPath) -or
(Get-Item $resolvedOutputPath).Length -eq 0) {
throw "GenAPI failed to export $PackageName $Version."
}
if ($genApiOutput -match 'Unable to resolve assembly') {
throw "GenAPI could not resolve the full dependency closure for $PackageName $Version."
}

Write-Host "Exported $PackageName $Version ($([System.IO.Path]::GetFileName($frameworkDirectory))) to $resolvedOutputPath"
} finally {
if (Test-Path $tempDirectory) {
Remove-Item -Recurse -Force $tempDirectory
}
}
Loading
Loading