From 49ffc6efe145e01f692818dfa8040c0ce18cf538 Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 20:14:59 -0600 Subject: [PATCH 1/9] Simplify the PR test matrix * Added a planner (build/compute-impacted-tests.ps1) that diffs against the PR target branch, detects changed analyzers/code fixes, dependency changes, and touched test files, builds a per-language plan (full vs class-filtered), publishes test-plan.json, and exposes AllTestsFull with a safe fallback to a full matrix on failure. * Updated the pipeline template (build/build-and-test.yml) to introduce a PlanTests job, publish the plan artifact, force full runs for C# 6 and the latest project, and gate the coverage stage on AllTestsFull; added a LatestLangVersion parameter. * Taught the test template (build/test.yml) to consume the plan, support forced-full runs, skip execution when no classes are selected, use xUnit response files for class filters, avoid coverage on partial runs, and tolerate missing result files when nothing runs. * Wired the root pipeline (azure-pipelines.yml) to pass the latest language version parameter. --- azure-pipelines.yml | 2 + build/build-and-test.yml | 39 ++- build/compute-impacted-tests.ps1 | 425 +++++++++++++++++++++++++++++++ build/test.yml | 102 +++++++- 4 files changed, 543 insertions(+), 25 deletions(-) create mode 100644 build/compute-impacted-tests.ps1 diff --git a/azure-pipelines.yml b/azure-pipelines.yml index ef1f3f898..427a84434 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -10,7 +10,9 @@ stages: - template: build/build-and-test.yml parameters: BuildConfiguration: Debug + LatestLangVersion: '13' - template: build/build-and-test.yml parameters: BuildConfiguration: Release + LatestLangVersion: '13' diff --git a/build/build-and-test.yml b/build/build-and-test.yml index 6e97ac159..246c7fe00 100644 --- a/build/build-and-test.yml +++ b/build/build-and-test.yml @@ -12,6 +12,10 @@ displayName: Platform type: string default: Any CPU +- name: LatestLangVersion + displayName: Latest C# test project version + type: string + default: '13' stages: - stage: Build_${{ parameters.BuildConfiguration }} @@ -44,18 +48,6 @@ stages: maximumCpuCount: false # AnnotatorBuildTask doesn't support parallel builds yet msbuildArgs: '/v:minimal /bl:$(Build.SourcesDirectory)/msbuild.binlog' -# - task: PowerShell@2 -# displayName: Upload coverage reports to codecov.io -# condition: eq(variables['BuildConfiguration'], 'Debug') -# inputs: -# workingDirectory: '$(Build.SourcesDirectory)/build' -# targetType: inline -# script: | -# $packageConfig = [xml](Get-Content ..\.nuget\packages.config) -# $codecov_version = $packageConfig.SelectSingleNode('/packages/package[@id="Codecov"]').version -# $codecov = "..\packages\Codecov.$codecov_version\tools\codecov.exe" -# &$codecov -f '..\build\OpenCover.Reports\OpenCover.StyleCopAnalyzers.xml' --required - - task: PublishPipelineArtifact@1 displayName: Publish build logs inputs: @@ -117,6 +109,24 @@ stages: targetPath: $(Build.SourcesDirectory)/StyleCop.Analyzers/StyleCop.Analyzers.Test.CSharp13/bin artifact: buildTest-cs13-${{ parameters.BuildConfiguration }} + - job: PlanTests + displayName: Plan impacted tests + steps: + - checkout: self + fetchDepth: 0 + - task: PowerShell@2 + name: setPlanOutputs + displayName: Compute impacted tests + inputs: + targetType: filePath + filePath: '$(Build.SourcesDirectory)/build/compute-impacted-tests.ps1' + arguments: "-OutputPath $(Pipeline.Workspace)/test-plan/test-plan.json -LatestLangVersion ${{ parameters.LatestLangVersion }}" + - task: PublishPipelineArtifact@1 + displayName: Publish test plan + inputs: + targetPath: $(Pipeline.Workspace)/test-plan + artifact: test-plan-${{ parameters.BuildConfiguration }} + - stage: Test_CSharp_6_${{ parameters.BuildConfiguration }} displayName: Test C# 6 ${{ parameters.BuildConfiguration }} dependsOn: [ 'Build_${{ parameters.BuildConfiguration }}' ] @@ -128,6 +138,7 @@ stages: BuildPlatform: ${{ parameters.BuildPlatform }} LangVersion: '6' FrameworkVersion: 'net452' + ForceFull: true - stage: Test_CSharp_7_${{ parameters.BuildConfiguration }} displayName: Test C# 7 ${{ parameters.BuildConfiguration }} @@ -212,10 +223,11 @@ stages: BuildPlatform: ${{ parameters.BuildPlatform }} LangVersion: '13' FrameworkVersion: 'net472' + ForceFull: ${{ eq(parameters.LatestLangVersion, '13') }} - stage: Publish_Code_Coverage_${{ parameters.BuildConfiguration }} displayName: Publish Code Coverage - condition: eq('${{ parameters.BuildConfiguration }}', 'Debug') + condition: and(eq('${{ parameters.BuildConfiguration }}', 'Debug'), eq(stageDependencies.Build_${{ parameters.BuildConfiguration }}.PlanTests.outputs['setPlanOutputs.AllTestsFull'], 'true')) dependsOn: - Test_CSharp_6_${{ parameters.BuildConfiguration }} - Test_CSharp_7_${{ parameters.BuildConfiguration }} @@ -225,6 +237,7 @@ stages: - Test_CSharp_11_${{ parameters.BuildConfiguration }} - Test_CSharp_12_${{ parameters.BuildConfiguration }} - Test_CSharp_13_${{ parameters.BuildConfiguration }} + - Build_${{ parameters.BuildConfiguration }} jobs: - job: WrapUp steps: diff --git a/build/compute-impacted-tests.ps1 b/build/compute-impacted-tests.ps1 new file mode 100644 index 000000000..e19c84378 --- /dev/null +++ b/build/compute-impacted-tests.ps1 @@ -0,0 +1,425 @@ +# Requires: PowerShell 5+ +[CmdletBinding()] +param( + [string]$OutputPath = "$PSScriptRoot\..\artifacts\test-plan.json", + [string]$LatestLangVersion, + [switch]$VerboseLogging +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Write-Info($message) { + Write-Host "[plan] $message" +} + +function Write-DebugInfo($message) { + if ($VerboseLogging) { + Write-Host "[plan:debug] $message" + } +} + +function Normalize-Path([string]$path) { + return [System.IO.Path]::GetFullPath($path) +} + +function Get-Namespace([string]$content) { + $match = [regex]::Match($content, 'namespace\s+([A-Za-z0-9_.]+)') + if ($match.Success) { + return $match.Groups[1].Value + } + + return '' +} + +function Get-ClassNames([string]$content) { + $matches = [regex]::Matches($content, '(class|struct)\s+([A-Za-z0-9_`]+)', 'IgnoreCase') + return ($matches | ForEach-Object { $_.Groups[2].Value }) | Sort-Object -Unique +} + +function Get-AnalyzerAndFixTypes([string]$content) { + $analyzers = New-Object System.Collections.Generic.HashSet[string] + $codeFixes = New-Object System.Collections.Generic.HashSet[string] + + $verifierMatches = [regex]::Matches( + $content, + 'StyleCopCodeFixVerifier<\s*([A-Za-z0-9_.]+)\s*,\s*([A-Za-z0-9_.]+)\s*>', + 'IgnoreCase') + foreach ($m in $verifierMatches) { + $null = $analyzers.Add($m.Groups[1].Value) + $null = $codeFixes.Add($m.Groups[2].Value) + } + + $diagnosticMatches = [regex]::Matches( + $content, + 'StyleCopDiagnosticVerifier<\s*([A-Za-z0-9_.]+)\s*>', + 'IgnoreCase') + foreach ($m in $diagnosticMatches) { + $null = $analyzers.Add($m.Groups[1].Value) + } + + $inheritanceMatches = [regex]::Matches( + $content, + ':\s*StyleCopCodeFixVerifier<\s*([A-Za-z0-9_.]+)\s*,\s*([A-Za-z0-9_.]+)\s*>', + 'IgnoreCase') + foreach ($m in $inheritanceMatches) { + $null = $analyzers.Add($m.Groups[1].Value) + $null = $codeFixes.Add($m.Groups[2].Value) + } + + return ,@($analyzers, $codeFixes) +} + +function Get-AnalyzerTypesFromSource([string]$content, [string]$namespace) { + $names = New-Object System.Collections.Generic.HashSet[string] + $matches = [regex]::Matches( + $content, + 'class\s+([A-Za-z0-9_`]+)[^{]*DiagnosticAnalyzer', + 'IgnoreCase') + foreach ($m in $matches) { + $short = $m.Groups[1].Value + $null = $names.Add($short) + if ($namespace) { + $null = $names.Add("$namespace.$short") + } + } + + return $names +} + +function Get-CodeFixTypesFromSource([string]$content, [string]$namespace) { + $names = New-Object System.Collections.Generic.HashSet[string] + $matches = [regex]::Matches( + $content, + 'class\s+([A-Za-z0-9_`]+)[^{]*CodeFixProvider', + 'IgnoreCase') + foreach ($m in $matches) { + $short = $m.Groups[1].Value + $null = $names.Add($short) + if ($namespace) { + $null = $names.Add("$namespace.$short") + } + } + + return $names +} + +function Get-TestMap([string]$testsRoot, [int[]]$languageVersions) { + $map = @{} + $filesToClasses = @{} + + foreach ($version in $languageVersions) { + $projectName = "StyleCop.Analyzers.Test.CSharp$version" + $projectPath = Join-Path $testsRoot $projectName + + if (-not (Test-Path $projectPath)) { + Write-Info "Skip missing test project for C# $version ($projectName)." + continue + } + + Write-Info "Indexing test project $projectName" + $sourceFiles = Get-ChildItem -Path $projectPath -Filter *.cs -Recurse -File + foreach ($file in $sourceFiles) { + $content = Get-Content -Path $file.FullName -Raw + $namespace = Get-Namespace $content + $classNames = Get-ClassNames $content + $analyzerSets = Get-AnalyzerAndFixTypes $content + $analyzerTypes = $analyzerSets[0] + $codeFixTypes = $analyzerSets[1] + + foreach ($className in $classNames) { + $fullName = if ($namespace) { "$namespace.$className" } else { $className } + $entry = @{ + Version = $version + ClassName = $fullName + File = Normalize-Path $file.FullName + Analyzers = @($analyzerTypes) + CodeFixes = @($codeFixTypes) + } + + $map[$fullName] = $entry + + if (-not $filesToClasses.ContainsKey($entry.File)) { + $filesToClasses[$entry.File] = New-Object System.Collections.Generic.List[string] + } + + $filesToClasses[$entry.File].Add($fullName) + } + } + } + + return ,@($map, $filesToClasses) +} + +function Try-GetChangedFiles([string]$targetRef) { + if (-not $targetRef) { + return @() + } + + $shortTarget = $targetRef -replace '^refs/heads/', '' + git fetch origin $shortTarget --quiet | Out-Null + + return @(git diff --name-only "origin/$shortTarget...HEAD") +} + +function Build-Plan { + $repoRoot = Normalize-Path (Join-Path $PSScriptRoot '..') + $testsRoot = Join-Path $repoRoot 'StyleCop.Analyzers' + + $isPullRequest = ($env:BUILD_REASON -eq 'PullRequest') + $targetBranch = $env:SYSTEM_PULLREQUEST_TARGETBRANCH + + $testProjects = Get-ChildItem -Path $testsRoot -Directory -Filter 'StyleCop.Analyzers.Test.CSharp*' + $versionNumbers = @($testProjects | ForEach-Object { + $m = [regex]::Match($_.Name, 'CSharp(\d+)') + if ($m.Success) { [int]$m.Groups[1].Value } + } | Sort-Object -Unique) + + $latest = if ($LatestLangVersion) { [int]$LatestLangVersion } else { ($versionNumbers | Sort-Object | Select-Object -Last 1) } + $allVersions = @(6) + $versionNumbers + $targetVersions = $versionNumbers | Where-Object { $_ -ge 7 -and $_ -lt $latest } + + $plan = [ordered]@{ + generatedAt = (Get-Date).ToString('o') + isPullRequest = $isPullRequest + latestLangVersion = $latest + dependencyChange = $false + plans = @{} + changedAnalyzers = @() + changedCodeFixes = @() + changedFiles = @() + } + + if (-not $isPullRequest) { + Write-Info "Build reason is '$($env:BUILD_REASON)'; default to full test matrix." + foreach ($v in $allVersions) { + $plan.plans[$v] = @{ + fullRun = $true + classes = @() + reason = 'Non-PR build' + } + } + + return $plan + } + + $changedFiles = Try-GetChangedFiles $targetBranch + if (-not $changedFiles -or $changedFiles.Count -eq 0) { + Write-Info "No changed files detected (target branch: $targetBranch); default to full test matrix." + foreach ($v in $allVersions) { + $plan.plans[$v] = @{ + fullRun = $true + classes = @() + reason = 'Unable to compute diff' + } + } + + return $plan + } + + $plan.changedFiles = $changedFiles + Write-Info ("Changed files ({0})" -f $changedFiles.Count) + + $dependencyChange = $false + $dependencyMarkers = @( + 'Directory.Build.props', + 'Directory.Build.targets', + 'global.json', + 'NuGet.config', + '.nuget/packages.config', + 'StyleCopAnalyzers.sln', + 'azure-pipelines.yml', + 'appveyor.yml', + 'init.ps1', + 'stylecop.json' + ) + + $testInfrastructureRoots = @( + 'StyleCop.Analyzers/StyleCop.Analyzers.Test/Verifiers', + 'StyleCop.Analyzers/StyleCop.Analyzers.Test/Helpers' + ) + + $changedAnalyzerNames = New-Object System.Collections.Generic.HashSet[string] + $changedCodeFixNames = New-Object System.Collections.Generic.HashSet[string] + $changedTestClasses = @{} + + foreach ($file in $changedFiles) { + $normalized = $file -replace '\\', '/' + + if ($dependencyMarkers | Where-Object { $normalized.StartsWith($_) }) { + $dependencyChange = $true + } + + if ($normalized -match '\.csproj$' -or $normalized -match '\.props$' -or $normalized -match '\.targets$') { + $dependencyChange = $true + } + + if ($normalized.StartsWith('build/')) { + $dependencyChange = $true + } + + foreach ($root in $testInfrastructureRoots) { + if ($normalized.StartsWith($root)) { + $dependencyChange = $true + } + } + } + + $plan.dependencyChange = $dependencyChange + + $mapResult = Get-TestMap -testsRoot $testsRoot -languageVersions $targetVersions + $testMap = $mapResult[0] + $fileToClass = $mapResult[1] + + foreach ($file in $changedFiles) { + $fullPath = Normalize-Path (Join-Path $repoRoot $file) + + if ($fileToClass.ContainsKey($fullPath)) { + foreach ($className in $fileToClass[$fullPath]) { + $version = $testMap[$className].Version + if (-not $changedTestClasses.ContainsKey($version)) { + $changedTestClasses[$version] = New-Object System.Collections.Generic.HashSet[string] + } + + $null = $changedTestClasses[$version].Add($className) + } + } + + if ($normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers.CodeFixes/*.cs' -or + $normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers.CodeFixes/*/*.cs' -or + $normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/*.cs' -or + $normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers.PrivateCodeFixes/*/*.cs') { + $content = Get-Content -Path $fullPath -Raw + $ns = Get-Namespace $content + $names = Get-CodeFixTypesFromSource $content $ns + foreach ($n in $names) { $null = $changedCodeFixNames.Add($n) } + } + + if ($normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers/*.cs' -or + $normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers/*/*.cs' -or + $normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers.PrivateAnalyzers/*.cs' -or + $normalized -like 'StyleCop.Analyzers/StyleCop.Analyzers.PrivateAnalyzers/*/*.cs') { + $content = Get-Content -Path $fullPath -Raw + $ns = Get-Namespace $content + $names = Get-AnalyzerTypesFromSource $content $ns + foreach ($n in $names) { $null = $changedAnalyzerNames.Add($n) } + } + } + + $plan.changedAnalyzers = @($changedAnalyzerNames) + $plan.changedCodeFixes = @($changedCodeFixNames) + + Write-Info ("Changed analyzers: {0}" -f ($plan.changedAnalyzers -join ', ')) + Write-Info ("Changed code fixes: {0}" -f ($plan.changedCodeFixes -join ', ')) + + $impactedByAnalyzer = @{} + foreach ($entry in $testMap.Values) { + $version = $entry.Version + if (-not $impactedByAnalyzer.ContainsKey($version)) { + $impactedByAnalyzer[$version] = New-Object System.Collections.Generic.HashSet[string] + } + + $hitAnalyzer = $entry.Analyzers | Where-Object { + $short = $_.Split('.')[-1] + $changedAnalyzerNames.Contains($_) -or $changedAnalyzerNames.Contains($short) + } + + $hitCodeFix = $entry.CodeFixes | Where-Object { + $short = $_.Split('.')[-1] + $changedCodeFixNames.Contains($_) -or $changedCodeFixNames.Contains($short) + } + + if ($hitAnalyzer -or $hitCodeFix) { + $null = $impactedByAnalyzer[$version].Add($entry.ClassName) + } + } + + foreach ($v in $allVersions) { + $entry = @{ + fullRun = $true + classes = @() + reason = '' + } + + if ($v -eq 6) { + $entry.reason = 'Always full for baseline test project' + } + elseif ($v -eq $latest) { + $entry.reason = 'Always full for newest language version' + } + elseif ($dependencyChange) { + $entry.reason = 'Dependency change detected' + } + else { + $impacted = @() + if ($changedTestClasses.ContainsKey($v)) { + $impacted += $changedTestClasses[$v] + } + + if ($impactedByAnalyzer.ContainsKey($v)) { + $impacted += $impactedByAnalyzer[$v] + } + + $impacted = $impacted | Sort-Object -Unique + + $entry.fullRun = $false + $entry.classes = $impacted + $entry.reason = if ($impacted.Count -eq 0) { 'No impacted tests' } else { 'Selected impacted test classes' } + } + + $plan.plans[$v] = $entry + } + + return $plan +} + +try { + $plan = Build-Plan + + $allFull = ($plan.plans.GetEnumerator() | Where-Object { -not $_.Value.fullRun }).Count -eq 0 + $json = $plan | ConvertTo-Json -Depth 6 + + $outFile = Normalize-Path $OutputPath + $outDir = Split-Path $outFile -Parent + if (-not (Test-Path $outDir)) { + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + } + + Set-Content -Path $outFile -Value $json -Encoding UTF8 + Write-Info "Test plan written to $outFile" + + $allFullValue = $allFull.ToString().ToLowerInvariant() + Write-Host "##vso[task.setvariable variable=AllTestsFull;isOutput=true]$allFullValue" +} +catch { + Write-Error $_ + Write-Info "Planner failed; falling back to full test matrix." + + $fallbackLatest = if ($LatestLangVersion) { [int]$LatestLangVersion } else { 13 } + $fallbackVersions = 6..$fallbackLatest + $fallback = [ordered]@{ + generatedAt = (Get-Date).ToString('o') + isPullRequest = $env:BUILD_REASON -eq 'PullRequest' + latestLangVersion = $fallbackLatest + dependencyChange = $true + plans = @{} + } + + foreach ($v in $fallbackVersions) { + $fallback.plans[$v] = @{ + fullRun = $true + classes = @() + reason = 'Planner failure fallback' + } + } + + $fallbackJson = $fallback | ConvertTo-Json -Depth 5 + $outFile = Normalize-Path $OutputPath + $outDir = Split-Path $outFile -Parent + if (-not (Test-Path $outDir)) { + New-Item -ItemType Directory -Path $outDir -Force | Out-Null + } + + Set-Content -Path $outFile -Value $fallbackJson -Encoding UTF8 + Write-Host "##vso[task.setvariable variable=AllTestsFull;isOutput=true]true" +} diff --git a/build/test.yml b/build/test.yml index 351f17b3d..51f9b7782 100644 --- a/build/test.yml +++ b/build/test.yml @@ -22,6 +22,10 @@ displayName: Platform type: string default: Any CPU +- name: ForceFull + displayName: Force full test run + type: boolean + default: false jobs: - job: Test_CSharp_${{ parameters.LangVersion }} @@ -35,6 +39,14 @@ jobs: inputs: versionSpec: 5.3.1 + - task: DownloadPipelineArtifact@2 + displayName: Download test plan + continueOnError: true + inputs: + buildType: current + artifactName: test-plan-${{ parameters.BuildConfiguration }} + targetPath: $(Pipeline.Workspace)/test-plan + - task: DownloadPipelineArtifact@2 displayName: 🔻 Download solution packages continueOnError: true @@ -68,6 +80,43 @@ jobs: workingDirectory: '$(Build.SourcesDirectory)/build' targetType: inline script: | + $runCoverage = 'true' + $forceFull = '${{ parameters.ForceFull }}' -eq 'true' + $planPath = "$(Pipeline.Workspace)\test-plan\test-plan.json" + $langVersion = '${{ parameters.LangVersion }}' + $selectionReason = 'Default full run' + $fullRun = $forceFull + $selectedClasses = @() + + if (-not $fullRun -and (Test-Path $planPath)) { + try { + $plan = Get-Content -Path $planPath -Raw | ConvertFrom-Json -ErrorAction Stop + $planEntry = $plan.plans.$langVersion + if ($planEntry) { + $fullRun = [bool]$planEntry.fullRun + $selectedClasses = @($planEntry.classes) + $selectionReason = $planEntry.reason + } else { + $selectionReason = "Missing plan entry for C# $langVersion" + $fullRun = $true + } + } catch { + Write-Host "Failed to parse test plan: $_" + $fullRun = $true + $selectionReason = 'Plan parse failure' + } + } elseif (-not $fullRun) { + $selectionReason = 'Plan not available' + $fullRun = $true + } + + if (-not $fullRun -and $selectedClasses.Count -eq 0) { + Write-Host "No impacted tests selected for C# $langVersion. Skipping execution." + $runCoverage = 'false' + Write-Host "##vso[task.setvariable variable=RunCoverage]$runCoverage" + exit 0 + } + $packageConfig = [xml](Get-Content ..\.nuget\packages.config) $opencover_version = $packageConfig.SelectSingleNode('/packages/package[@id="OpenCover"]').version $xunitrunner_version = $packageConfig.SelectSingleNode('/packages/package[@id="xunit.runner.console"]').version @@ -76,20 +125,47 @@ jobs: $opencover_console = "$packages_folder\OpenCover.$opencover_version\tools\OpenCover.Console.exe" $xunit_runner_console_${{ parameters.FrameworkVersion }} = "$packages_folder\xunit.runner.console.$xunitrunner_version\tools\${{ parameters.FrameworkVersion }}\xunit.console.x86.exe" $report_folder = '.\OpenCover.Reports' - mkdir $report_folder + New-Item -ItemType Directory -Path $report_folder -Force | Out-Null $target_dll_name = If ('${{ parameters.LangVersion }}' -Eq '6') { "StyleCop.Analyzers.Test" } Else { "StyleCop.Analyzers.Test.CSharp${{ parameters.LangVersion }}" } $target_dll_csharp${{ parameters.LangVersion }} = "..\StyleCop.Analyzers\$target_dll_name\bin\${{ parameters.BuildConfiguration }}\${{ parameters.FrameworkVersion }}\$target_dll_name.dll" - &$opencover_console ` - -register:Path32 ` - -threshold:1 -oldStyle ` - -returntargetcode ` - -hideskipped:All ` - -filter:"+[StyleCop*]*" ` - -excludebyattribute:*.ExcludeFromCodeCoverage* ` - -excludebyfile:*\*Designer.cs ` - -output:"$report_folder\OpenCover.StyleCopAnalyzers.CSharp${{ parameters.LangVersion }}.xml" ` - -target:"$xunit_runner_console_${{ parameters.FrameworkVersion }}" ` - -targetargs:"$target_dll_csharp${{ parameters.LangVersion }} -noshadow -xml StyleCopAnalyzers.CSharp${{ parameters.LangVersion }}.xunit.xml" + $targetArgsArray = @("$target_dll_csharp${{ parameters.LangVersion }}", "-noshadow", "-xml", "StyleCopAnalyzers.CSharp${{ parameters.LangVersion }}.xunit.xml") + if (-not $fullRun) { + $responseFile = Join-Path (Get-Location) "xunit-classes-$langVersion.rsp" + $responseLines = @("`"$target_dll_csharp${{ parameters.LangVersion }}`"", "-noshadow", "-xml", "StyleCopAnalyzers.CSharp${{ parameters.LangVersion }}.xunit.xml") + foreach ($class in $selectedClasses) { + $responseLines += "-class" + $responseLines += "`"$class`"" + } + + Set-Content -Path $responseFile -Value $responseLines -Encoding UTF8 + $targetArgsArray = @("@`"$responseFile`"") + $runCoverage = 'false' + } + + Write-Host "Test selection: $selectionReason" + if (-not $fullRun) { + Write-Host ("Selected {0} test classes" -f $selectedClasses.Count) + } + + Write-Host "##vso[task.setvariable variable=RunCoverage]$runCoverage" + + $targetArgs = ($targetArgsArray -join ' ') + + if ($runCoverage -eq 'true') { + &$opencover_console ` + -register:Path32 ` + -threshold:1 -oldStyle ` + -returntargetcode ` + -hideskipped:All ` + -filter:"+[StyleCop*]*" ` + -excludebyattribute:*.ExcludeFromCodeCoverage* ` + -excludebyfile:*\*Designer.cs ` + -output:"$report_folder\OpenCover.StyleCopAnalyzers.CSharp${{ parameters.LangVersion }}.xml" ` + -target:"$xunit_runner_console_${{ parameters.FrameworkVersion }}" ` + -targetargs:"$targetArgs" + } else { + &$xunit_runner_console_${{ parameters.FrameworkVersion }} @targetArgsArray + } - task: PublishTestResults@2 displayName: 📢 Publish test results @@ -98,11 +174,13 @@ jobs: testResultsFormat: xUnit testResultsFiles: 'build/*.xml' mergeTestResults: true + failTaskOnMissingResults: false testRunTitle: 'C# ${{ parameters.LangVersion }} ${{ parameters.BuildConfiguration }}' - ${{ if eq(parameters.BuildConfiguration, 'Debug') }}: - task: PublishPipelineArtifact@1 displayName: Publish code coverage + condition: and(succeeded(), eq(variables['RunCoverage'], 'true')) inputs: targetPath: $(Build.SourcesDirectory)/build/OpenCover.Reports artifact: coverageResults-cs${{ parameters.LangVersion }} From 974ec39b8486ba7d089673d96e164457d914e4cf Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 20:26:39 -0600 Subject: [PATCH 2/9] fixup! Simplify the PR test matrix --- build/compute-impacted-tests.ps1 | 34 +++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/build/compute-impacted-tests.ps1 b/build/compute-impacted-tests.ps1 index e19c84378..7af499991 100644 --- a/build/compute-impacted-tests.ps1 +++ b/build/compute-impacted-tests.ps1 @@ -153,13 +153,25 @@ function Get-TestMap([string]$testsRoot, [int[]]$languageVersions) { function Try-GetChangedFiles([string]$targetRef) { if (-not $targetRef) { + Write-Info "Target branch not provided; unable to compute diff." return @() } $shortTarget = $targetRef -replace '^refs/heads/', '' - git fetch origin $shortTarget --quiet | Out-Null - return @(git diff --name-only "origin/$shortTarget...HEAD") + try { + git fetch origin $shortTarget --quiet 2>$null | Out-Null + } catch { + Write-Info "Git fetch failed for origin/${shortTarget}: $_" + return @() + } + + try { + return @(git diff --name-only "origin/${shortTarget}...HEAD" 2>$null) + } catch { + Write-Info "Git diff failed for origin/${shortTarget}: $_" + return @() + } } function Build-Plan { @@ -193,7 +205,8 @@ function Build-Plan { if (-not $isPullRequest) { Write-Info "Build reason is '$($env:BUILD_REASON)'; default to full test matrix." foreach ($v in $allVersions) { - $plan.plans[$v] = @{ + $key = "$v" + $plan.plans[$key] = @{ fullRun = $true classes = @() reason = 'Non-PR build' @@ -203,8 +216,9 @@ function Build-Plan { return $plan } - $changedFiles = Try-GetChangedFiles $targetBranch - if (-not $changedFiles -or $changedFiles.Count -eq 0) { + $changedFiles = @(Try-GetChangedFiles $targetBranch) + $changedFilesCount = ($changedFiles | Measure-Object).Count + if ($changedFilesCount -eq 0) { Write-Info "No changed files detected (target branch: $targetBranch); default to full test matrix." foreach ($v in $allVersions) { $plan.plans[$v] = @{ @@ -218,7 +232,7 @@ function Build-Plan { } $plan.changedFiles = $changedFiles - Write-Info ("Changed files ({0})" -f $changedFiles.Count) + Write-Info ("Changed files ({0})" -f $changedFilesCount) $dependencyChange = $false $dependencyMarkers = @( @@ -335,6 +349,7 @@ function Build-Plan { } foreach ($v in $allVersions) { + $key = "$v" $entry = @{ fullRun = $true classes = @() @@ -367,7 +382,7 @@ function Build-Plan { $entry.reason = if ($impacted.Count -eq 0) { 'No impacted tests' } else { 'Selected impacted test classes' } } - $plan.plans[$v] = $entry + $plan.plans[$key] = $entry } return $plan @@ -376,7 +391,7 @@ function Build-Plan { try { $plan = Build-Plan - $allFull = ($plan.plans.GetEnumerator() | Where-Object { -not $_.Value.fullRun }).Count -eq 0 + $allFull = (($plan.plans.GetEnumerator() | Where-Object { -not $_.Value.fullRun }) | Measure-Object).Count -eq 0 $json = $plan | ConvertTo-Json -Depth 6 $outFile = Normalize-Path $OutputPath @@ -406,7 +421,8 @@ catch { } foreach ($v in $fallbackVersions) { - $fallback.plans[$v] = @{ + $key = "$v" + $fallback.plans[$key] = @{ fullRun = $true classes = @() reason = 'Planner failure fallback' From 2240aecfce54bcd0aca978ecc761ae9bae6c2041 Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 20:48:08 -0600 Subject: [PATCH 3/9] Continue to run code coverage even on partial test runs --- build/build-and-test.yml | 2 +- build/test.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/build/build-and-test.yml b/build/build-and-test.yml index 246c7fe00..df3d22375 100644 --- a/build/build-and-test.yml +++ b/build/build-and-test.yml @@ -227,7 +227,7 @@ stages: - stage: Publish_Code_Coverage_${{ parameters.BuildConfiguration }} displayName: Publish Code Coverage - condition: and(eq('${{ parameters.BuildConfiguration }}', 'Debug'), eq(stageDependencies.Build_${{ parameters.BuildConfiguration }}.PlanTests.outputs['setPlanOutputs.AllTestsFull'], 'true')) + condition: eq('${{ parameters.BuildConfiguration }}', 'Debug') dependsOn: - Test_CSharp_6_${{ parameters.BuildConfiguration }} - Test_CSharp_7_${{ parameters.BuildConfiguration }} diff --git a/build/test.yml b/build/test.yml index 51f9b7782..ae84264f7 100644 --- a/build/test.yml +++ b/build/test.yml @@ -139,7 +139,6 @@ jobs: Set-Content -Path $responseFile -Value $responseLines -Encoding UTF8 $targetArgsArray = @("@`"$responseFile`"") - $runCoverage = 'false' } Write-Host "Test selection: $selectionReason" @@ -180,7 +179,7 @@ jobs: - ${{ if eq(parameters.BuildConfiguration, 'Debug') }}: - task: PublishPipelineArtifact@1 displayName: Publish code coverage - condition: and(succeeded(), eq(variables['RunCoverage'], 'true')) + condition: succeeded() inputs: targetPath: $(Build.SourcesDirectory)/build/OpenCover.Reports artifact: coverageResults-cs${{ parameters.LangVersion }} From 5d219c4a19d41e9194117ba492cf7f0e591efc7c Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 20:48:51 -0600 Subject: [PATCH 4/9] fixup! Simplify the PR test matrix --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3fe9dd0d8..1570f89b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build output bin/ obj/ +artifacts/ # Artifacts of the IDE and build *.sln.ide/ From 8056664d8dd2acc053e177616506319f18c10b94 Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 21:00:12 -0600 Subject: [PATCH 5/9] Add ability to run filtered tests locally * Added build/Run-ImpactedTests.ps1, which builds (unless -NoBuild), computes impacted tests via the planner against a target branch (default upstream/master), and runs only the needed xUnit test classes locally while always exercising C# 6 and the latest suite. Results drop under artifacts/test-results, with options for -LangVersions and -VerboseLogging. * Enhanced build/compute-impacted-tests.ps1 to accept -TargetBranch and -AssumePullRequest, enabling local diff-based planning without a PR pipeline. * Documented the local selective test workflow in CONTRIBUTING.md, including usage examples and options. --- CONTRIBUTING.md | 17 ++++ build/Run-ImpactedTests.ps1 | 158 +++++++++++++++++++++++++++++++ build/compute-impacted-tests.ps1 | 27 +++++- 3 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 build/Run-ImpactedTests.ps1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8917cb3a6..122419c02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,23 @@ You can also help by filing issues, participating in discussions and doing code * The version of the [.NET Core SDK](https://dotnet.microsoft.com/download/dotnet-core) as specified in the global.json file at the root of this repo. Use the init script at the root of the repo to conveniently acquire and install the right version. +## Running tests locally + +To run only the tests impacted by changes in your local branch, use the helper script: + +```powershell +pwsh -File build/Run-ImpactedTests.ps1 -TargetBranch upstream/master -Configuration Debug +``` + +What it does: + +* Builds the solution (skip with `-NoBuild` if you already built). +* Computes impacted test classes by diffing your branch against the specified target branch (default `upstream/master`). +* Always runs full suites for the C# 6 and latest test projects; runs class-filtered suites for other language versions when possible. +* Outputs xUnit results under `artifacts/test-results`. + +You can limit languages with `-LangVersions 6,7,13`, or enable verbose logging with `-VerboseLogging`. The planner logic lives in `build/compute-impacted-tests.ps1` if you need to inspect or tweak its behavior. + ## Implementing a diagnostic 1. To start working on a diagnostic, add a comment to the issue indicating you are working on implementing it. diff --git a/build/Run-ImpactedTests.ps1 b/build/Run-ImpactedTests.ps1 new file mode 100644 index 000000000..cbe7b8c46 --- /dev/null +++ b/build/Run-ImpactedTests.ps1 @@ -0,0 +1,158 @@ +# Runs only impacted test classes locally based on git diff with a target branch. +# .SYNOPSIS +# Runs locally impacted StyleCop analyzer tests based on git diff against a target branch. +# .DESCRIPTION +# Builds (optional) and executes xUnit tests using the impacted-test planner. Always runs full +# suites for C# 6 and the latest test project; other language versions run class-filtered tests +# when possible. Writes xUnit XML results to artifacts\test-results. +# .PARAMETER Configuration +# Build configuration to use (Debug/Release). Defaults to Debug. +# .PARAMETER TargetBranch +# Branch or ref to diff against when computing impacted tests (default: upstream/master). +# .PARAMETER LatestLangVersion +# Highest C# test project version; treated as "latest" for always-full runs. Default: 13. +# .PARAMETER LangVersions +# Optional list of C# language versions to run (e.g., 6,7,13). Defaults to all known versions in plan. +# .PARAMETER NoBuild +# Skip building the solution before running tests. +# .PARAMETER VerboseLogging +# Emit additional diagnostic output from the planner and runner. +[CmdletBinding()] +param( + [string]$Configuration = 'Debug', + [string]$TargetBranch = 'upstream/master', + [string]$LatestLangVersion = '13', + [string[]]$LangVersions, + [switch]$NoBuild, + [switch]$VerboseLogging +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Join-Path $PSScriptRoot '..') +$planPath = Join-Path $repoRoot 'artifacts\test-plan.json' +$resultsRoot = Join-Path $repoRoot 'artifacts\test-results' + +function Write-Info($message) { Write-Host "[local-tests] $message" } +function Write-DebugInfo($message) { if ($VerboseLogging) { Write-Host "[local-tests:debug] $message" } } + +Push-Location $repoRoot +try { + Write-Info "Restoring tools (init.ps1)" + & "$repoRoot\init.ps1" + + if (-not $NoBuild) { + Write-Info "Building solution (Configuration=$Configuration)" + & dotnet build "$repoRoot\StyleCopAnalyzers.sln" -c $Configuration + } else { + Write-Info "Skipping build because -NoBuild was specified" + } + + Write-Info "Computing impacted tests relative to $TargetBranch" + & "$PSScriptRoot\compute-impacted-tests.ps1" ` + -OutputPath $planPath ` + -LatestLangVersion $LatestLangVersion ` + -TargetBranch $TargetBranch ` + -AssumePullRequest ` + -VerboseLogging:$VerboseLogging + + if (-not (Test-Path $planPath)) { + throw "Test plan not found at $planPath" + } + + $plan = Get-Content -Path $planPath -Raw | ConvertFrom-Json + + $planLangs = @($plan.plans.PSObject.Properties.Name) + if (-not $LangVersions -or $LangVersions.Count -eq 0) { + $LangVersions = $planLangs | Sort-Object {[int]$_} + } + + Write-Info ("Target languages: {0}" -f ($LangVersions -join ', ')) + + $packageConfig = [xml](Get-Content "$repoRoot\.nuget\packages.config") + $xunitrunner_version = $packageConfig.SelectSingleNode('/packages/package[@id="xunit.runner.console"]').version + + $frameworkMap = @{ + '6' = 'net452' + '7' = 'net46' + '8' = 'net472' + '9' = 'net472' + '10' = 'net472' + '11' = 'net472' + '12' = 'net472' + '13' = 'net472' + } + + New-Item -ItemType Directory -Force -Path $resultsRoot | Out-Null + + $failures = 0 + + foreach ($lang in $LangVersions) { + if (-not $plan.plans.$lang) { + Write-Info "No plan entry for C# $lang. Skipping." + continue + } + + $entry = $plan.plans.$lang + $fullRun = [bool]$entry.fullRun + $classes = @($entry.classes) + $reason = $entry.reason + $frameworkVersion = $frameworkMap[$lang] + + if (-not $frameworkVersion) { + Write-Info "Unknown framework mapping for C# $lang. Skipping." + continue + } + + $projectName = if ($lang -eq '6') { 'StyleCop.Analyzers.Test' } else { "StyleCop.Analyzers.Test.CSharp$lang" } + $dllPath = Join-Path $repoRoot "StyleCop.Analyzers\$projectName\bin\$Configuration\$frameworkVersion\$projectName.dll" + + if (-not (Test-Path $dllPath)) { + Write-Info "Test assembly not found for C# $lang at $dllPath. Re-run without -NoBuild." + $failures++ + continue + } + + $runner = Join-Path $repoRoot "packages\xunit.runner.console.$xunitrunner_version\tools\$frameworkVersion\xunit.console.x86.exe" + if (-not (Test-Path $runner)) { + Write-Info "xUnit runner not found at $runner. Ensure packages are restored." + $failures++ + continue + } + + $xmlPath = Join-Path $resultsRoot "StyleCopAnalyzers.CSharp$lang.xunit.xml" + $args = @($dllPath, '-noshadow', '-xml', $xmlPath) + + if (-not $fullRun) { + if ($classes.Count -eq 0) { + Write-Info "No impacted tests for C# $lang ($reason). Skipping." + continue + } + + Write-Info ("Running {0} selected test classes for C# {1} ({2})" -f $classes.Count, $lang, $reason) + foreach ($c in $classes) { + $args += '-class' + $args += $c + } + } else { + Write-Info "Running full suite for C# $lang ($reason)" + } + + Write-DebugInfo ("Runner: {0}" -f $runner) + Write-DebugInfo ("Args: {0}" -f ($args -join ' ')) + + & $runner @args + if ($LASTEXITCODE -ne 0) { + Write-Info "Tests failed for C# $lang (exit $LASTEXITCODE)" + $failures++ + } + } + + if ($failures -ne 0) { + throw "$failures test invocation(s) failed." + } +} +finally { + Pop-Location +} diff --git a/build/compute-impacted-tests.ps1 b/build/compute-impacted-tests.ps1 index 7af499991..278b4ebe8 100644 --- a/build/compute-impacted-tests.ps1 +++ b/build/compute-impacted-tests.ps1 @@ -1,8 +1,26 @@ -# Requires: PowerShell 5+ +# Requires: PowerShell 5+ +# .SYNOPSIS +# Computes an impacted-test plan from git diff and writes test-plan.json for the pipeline. +# .DESCRIPTION +# Identifies changed analyzers/code fixes/tests/dependencies relative to a target branch, selects +# full vs filtered test execution per language version, and emits an Azure Pipelines variable +# `AllTestsFull` indicating whether every language should run a full suite. +# .PARAMETER OutputPath +# Path to write the generated JSON plan (default: artifacts\test-plan.json). +# .PARAMETER LatestLangVersion +# Highest C# test project version; newest test project is always run in full. +# .PARAMETER TargetBranch +# Branch or ref to diff against (e.g., upstream/master). Required when using -AssumePullRequest. +# .PARAMETER AssumePullRequest +# Forces PR-mode selection when running locally (otherwise uses BUILD_REASON). +# .PARAMETER VerboseLogging +# Emit additional diagnostic output while computing the plan. [CmdletBinding()] param( [string]$OutputPath = "$PSScriptRoot\..\artifacts\test-plan.json", [string]$LatestLangVersion, + [string]$TargetBranch, + [switch]$AssumePullRequest, [switch]$VerboseLogging ) @@ -178,8 +196,11 @@ function Build-Plan { $repoRoot = Normalize-Path (Join-Path $PSScriptRoot '..') $testsRoot = Join-Path $repoRoot 'StyleCop.Analyzers' - $isPullRequest = ($env:BUILD_REASON -eq 'PullRequest') - $targetBranch = $env:SYSTEM_PULLREQUEST_TARGETBRANCH + $isPullRequest = ($env:BUILD_REASON -eq 'PullRequest') -or $AssumePullRequest.IsPresent + $targetBranch = if ($TargetBranch) { $TargetBranch } else { $env:SYSTEM_PULLREQUEST_TARGETBRANCH } + if (-not $targetBranch -and $AssumePullRequest) { + $targetBranch = 'upstream/master' + } $testProjects = Get-ChildItem -Path $testsRoot -Directory -Filter 'StyleCop.Analyzers.Test.CSharp*' $versionNumbers = @($testProjects | ForEach-Object { From 34617d0473263030b7cd9e5d0756069930929baa Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 21:12:42 -0600 Subject: [PATCH 6/9] fixup! Add ability to run filtered tests locally --- build/Install-DotNetSdk.ps1 | 31 ++++++++++++++++++++++++------- build/Run-ImpactedTests.ps1 | 11 ++++++++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/build/Install-DotNetSdk.ps1 b/build/Install-DotNetSdk.ps1 index 202dd8e0b..5dcb909b3 100644 --- a/build/Install-DotNetSdk.ps1 +++ b/build/Install-DotNetSdk.ps1 @@ -20,6 +20,10 @@ Param ( [string]$InstallLocality='user' ) +# Compatibility: define platform helper variables if not available (Windows PowerShell 5) +if (-not (Get-Variable -Name IsMacOS -Scope Global -ErrorAction SilentlyContinue)) { $script:IsMacOS = $false } +if (-not (Get-Variable -Name IsLinux -Scope Global -ErrorAction SilentlyContinue)) { $script:IsLinux = $false } + $DotNetInstallScriptRoot = "$PSScriptRoot/../obj/tools" if (!(Test-Path $DotNetInstallScriptRoot)) { New-Item -ItemType Directory -Path $DotNetInstallScriptRoot | Out-Null } $DotNetInstallScriptRoot = Resolve-Path $DotNetInstallScriptRoot @@ -31,14 +35,27 @@ $sdkVersion = $globalJson.sdk.version # Search for all .NET Core runtime versions referenced from MSBuild projects and arrange to install them. $runtimeVersions = @() Get-ChildItem "$PSScriptRoot\..\*.*proj" -Recurse |% { - $projXml = [xml](Get-Content -Path $_) - $targetFrameworks = $projXml.Project.PropertyGroup.TargetFramework - if (!$targetFrameworks) { - $targetFrameworks = $projXml.Project.PropertyGroup.TargetFrameworks - if ($targetFrameworks) { - $targetFrameworks = $targetFrameworks -Split ';' - } + try { + $projXml = [xml](Get-Content -Path $_) + } catch { + return } + + if (-not $projXml.Project) { + return + } + + $tfNodes = $projXml.SelectNodes('//Project/PropertyGroup/TargetFramework') + $tfmsNodes = $projXml.SelectNodes('//Project/PropertyGroup/TargetFrameworks') + + $targetFrameworks = @() + if ($tfNodes) { + $targetFrameworks += ($tfNodes | ForEach-Object { $_.InnerText }) + } + if ($tfmsNodes) { + $targetFrameworks += ($tfmsNodes | ForEach-Object { $_.InnerText -split ';' }) + } + $targetFrameworks |? { $_ -match 'netcoreapp(\d+\.\d+)' } |% { $runtimeVersions += $Matches[1] } diff --git a/build/Run-ImpactedTests.ps1 b/build/Run-ImpactedTests.ps1 index cbe7b8c46..86450f9b0 100644 --- a/build/Run-ImpactedTests.ps1 +++ b/build/Run-ImpactedTests.ps1 @@ -30,7 +30,8 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$repoRoot = Split-Path -Parent (Join-Path $PSScriptRoot '..') +# Resolve repository root (parent of /build) +$repoRoot = Split-Path -Parent $PSScriptRoot $planPath = Join-Path $repoRoot 'artifacts\test-plan.json' $resultsRoot = Join-Path $repoRoot 'artifacts\test-results' @@ -39,8 +40,12 @@ function Write-DebugInfo($message) { if ($VerboseLogging) { Write-Host "[local-t Push-Location $repoRoot try { - Write-Info "Restoring tools (init.ps1)" - & "$repoRoot\init.ps1" + if (-not $NoBuild) { + Write-Info "Restoring tools (init.ps1)" + & "$repoRoot\init.ps1" + } else { + Write-Info "Skipping init.ps1 because -NoBuild was specified" + } if (-not $NoBuild) { Write-Info "Building solution (Configuration=$Configuration)" From 11497871e751966eb7de3cfd5994f45c5145be4f Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 21:21:12 -0600 Subject: [PATCH 7/9] fixup! Add ability to run filtered tests locally --- build/compute-impacted-tests.ps1 | 53 +++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/build/compute-impacted-tests.ps1 b/build/compute-impacted-tests.ps1 index 278b4ebe8..22b365d13 100644 --- a/build/compute-impacted-tests.ps1 +++ b/build/compute-impacted-tests.ps1 @@ -176,20 +176,57 @@ function Try-GetChangedFiles([string]$targetRef) { } $shortTarget = $targetRef -replace '^refs/heads/', '' + $candidates = New-Object System.Collections.Generic.List[hashtable] + $candidates.Add(@{ Fetch = 'origin'; Ref = $shortTarget; DiffRef = "origin/$shortTarget" }) - try { - git fetch origin $shortTarget --quiet 2>$null | Out-Null - } catch { - Write-Info "Git fetch failed for origin/${shortTarget}: $_" - return @() + if ($targetRef -notmatch '^origin/' -and $shortTarget -ne $targetRef) { + $candidates.Add(@{ Fetch = 'origin'; Ref = $targetRef; DiffRef = "origin/$targetRef" }) } + $remotes = @() try { - return @(git diff --name-only "origin/${shortTarget}...HEAD" 2>$null) + $remotes = @(git remote) } catch { - Write-Info "Git diff failed for origin/${shortTarget}: $_" - return @() + $remotes = @() + } + + $parts = $targetRef.Split('/', 2) + if ($parts.Length -eq 2 -and $remotes -contains $parts[0]) { + $candidates.Add(@{ Fetch = $parts[0]; Ref = $parts[1]; DiffRef = "$($parts[0])/$($parts[1])" }) } + + $candidates.Add(@{ Fetch = $null; Ref = $null; DiffRef = $targetRef }) + + $failureMessages = New-Object System.Collections.Generic.List[string] + + foreach ($candidate in $candidates) { + $diffRef = $candidate.DiffRef + + if ($candidate.Fetch) { + try { + git fetch $candidate.Fetch $candidate.Ref --quiet 2>$null | Out-Null + } catch { + $failureMessages.Add("Git fetch failed for $($candidate.Fetch)/$($candidate.Ref): $_") + continue + } + } + + try { + $changes = @(git diff --name-only "$diffRef...HEAD" 2>$null) + Write-Info "Using diff base $diffRef" + return $changes + } catch { + $failureMessages.Add(("Git diff failed for {0}: {1}" -f $diffRef, $_)) + continue + } + } + + foreach ($msg in $failureMessages) { + Write-Info $msg + } + + Write-Info "Unable to compute diff for $targetRef; defaulting to full run." + return @() } function Build-Plan { From 488279bdcb739ccf472f77b508fb82b22e1b3942 Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 21:23:27 -0600 Subject: [PATCH 8/9] Add dry run support for local filtered tests --- build/Run-ImpactedTests.ps1 | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/build/Run-ImpactedTests.ps1 b/build/Run-ImpactedTests.ps1 index 86450f9b0..f52debc74 100644 --- a/build/Run-ImpactedTests.ps1 +++ b/build/Run-ImpactedTests.ps1 @@ -17,6 +17,8 @@ # Skip building the solution before running tests. # .PARAMETER VerboseLogging # Emit additional diagnostic output from the planner and runner. +# .PARAMETER DryRun +# Compute and report the impacted test plan without executing any tests. [CmdletBinding()] param( [string]$Configuration = 'Debug', @@ -24,7 +26,8 @@ param( [string]$LatestLangVersion = '13', [string[]]$LangVersions, [switch]$NoBuild, - [switch]$VerboseLogging + [switch]$VerboseLogging, + [switch]$DryRun ) Set-StrictMode -Version Latest @@ -40,6 +43,10 @@ function Write-DebugInfo($message) { if ($VerboseLogging) { Write-Host "[local-t Push-Location $repoRoot try { + if ($DryRun) { + $NoBuild = $true + } + if (-not $NoBuild) { Write-Info "Restoring tools (init.ps1)" & "$repoRoot\init.ps1" @@ -75,6 +82,22 @@ try { Write-Info ("Target languages: {0}" -f ($LangVersions -join ', ')) + if ($DryRun) { + Write-Info "Dry run: computed plan only (no tests will be executed)." + Write-Info "Plan file: $planPath" + foreach ($lang in $LangVersions) { + if (-not $plan.plans.$lang) { + Write-Info ("C# {0}: no entry in plan." -f $lang) + continue + } + + $entry = $plan.plans.$lang + $summary = if ($entry.fullRun) { 'full run' } else { "filtered ($($entry.classes.Count) classes)" } + Write-Info ("C# {0}: {1} ({2})" -f $lang, $summary, $entry.reason) + } + return + } + $packageConfig = [xml](Get-Content "$repoRoot\.nuget\packages.config") $xunitrunner_version = $packageConfig.SelectSingleNode('/packages/package[@id="xunit.runner.console"]').version From 18ada66e660406e4e512fd053eaffd448955a112 Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Sun, 30 Nov 2025 21:32:12 -0600 Subject: [PATCH 9/9] Allow target branch to be omitted * Updated build/Run-ImpactedTests.ps1 to auto-detect the target branch when none is provided: it now looks for a git remote pointing at DotNetAnalyzers/StyleCopAnalyzers (HTTPS or SSH) and uses /master; otherwise it falls back to origin/master. The resolved branch is logged. * The TargetBranch parameter default is now empty to trigger auto-detection, and the help text describes the behavior. --- build/Run-ImpactedTests.ps1 | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/build/Run-ImpactedTests.ps1 b/build/Run-ImpactedTests.ps1 index f52debc74..9f67061c8 100644 --- a/build/Run-ImpactedTests.ps1 +++ b/build/Run-ImpactedTests.ps1 @@ -8,7 +8,8 @@ # .PARAMETER Configuration # Build configuration to use (Debug/Release). Defaults to Debug. # .PARAMETER TargetBranch -# Branch or ref to diff against when computing impacted tests (default: upstream/master). +# Branch or ref to diff against when computing impacted tests. If omitted, attempts to locate a +# remote pointing at DotNetAnalyzers/StyleCopAnalyzers and uses its master branch. # .PARAMETER LatestLangVersion # Highest C# test project version; treated as "latest" for always-full runs. Default: 13. # .PARAMETER LangVersions @@ -22,7 +23,7 @@ [CmdletBinding()] param( [string]$Configuration = 'Debug', - [string]$TargetBranch = 'upstream/master', + [string]$TargetBranch = '', [string]$LatestLangVersion = '13', [string[]]$LangVersions, [switch]$NoBuild, @@ -61,11 +62,38 @@ try { Write-Info "Skipping build because -NoBuild was specified" } - Write-Info "Computing impacted tests relative to $TargetBranch" + $resolvedTargetBranch = $TargetBranch + if ([string]::IsNullOrWhiteSpace($resolvedTargetBranch)) { + $resolvedTargetBranch = 'origin/master' + try { + $remoteLines = @(git remote -v 2>$null) + $targetRemote = $remoteLines | + Where-Object { $_ -match '^(?[^\s]+)\s+(?\S+)' } | + ForEach-Object { + $m = [regex]::Match($_, '^(?[^\s]+)\s+(?\S+)') + if ($m.Success) { + [pscustomobject]@{ Name = $m.Groups['name'].Value; Url = $m.Groups['url'].Value } + } + } | + Where-Object { $_.Url -match 'github\.com[:/]+DotNetAnalyzers/StyleCopAnalyzers(\.git)?' } | + Select-Object -First 1 + + if ($targetRemote) { + $resolvedTargetBranch = "$($targetRemote.Name)/master" + Write-Info "Detected target branch '$resolvedTargetBranch' from remote '$($targetRemote.Url)'" + } else { + Write-Info "No matching remote found; defaulting target branch to '$resolvedTargetBranch'" + } + } catch { + Write-Info "Remote detection failed; defaulting target branch to '$resolvedTargetBranch'" + } + } + + Write-Info "Computing impacted tests relative to $resolvedTargetBranch" & "$PSScriptRoot\compute-impacted-tests.ps1" ` -OutputPath $planPath ` -LatestLangVersion $LatestLangVersion ` - -TargetBranch $TargetBranch ` + -TargetBranch $resolvedTargetBranch ` -AssumePullRequest ` -VerboseLogging:$VerboseLogging