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/ 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/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/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 new file mode 100644 index 000000000..9f67061c8 --- /dev/null +++ b/build/Run-ImpactedTests.ps1 @@ -0,0 +1,214 @@ +# 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. 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 +# 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. +# .PARAMETER DryRun +# Compute and report the impacted test plan without executing any tests. +[CmdletBinding()] +param( + [string]$Configuration = 'Debug', + [string]$TargetBranch = '', + [string]$LatestLangVersion = '13', + [string[]]$LangVersions, + [switch]$NoBuild, + [switch]$VerboseLogging, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# 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' + +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 { + if ($DryRun) { + $NoBuild = $true + } + + 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)" + & dotnet build "$repoRoot\StyleCopAnalyzers.sln" -c $Configuration + } else { + Write-Info "Skipping build because -NoBuild was specified" + } + + $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 $resolvedTargetBranch ` + -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 ', ')) + + 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 + + $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/build-and-test.yml b/build/build-and-test.yml index 6e97ac159..df3d22375 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,6 +223,7 @@ stages: BuildPlatform: ${{ parameters.BuildPlatform }} LangVersion: '13' FrameworkVersion: 'net472' + ForceFull: ${{ eq(parameters.LatestLangVersion, '13') }} - stage: Publish_Code_Coverage_${{ parameters.BuildConfiguration }} displayName: Publish Code Coverage @@ -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..22b365d13 --- /dev/null +++ b/build/compute-impacted-tests.ps1 @@ -0,0 +1,499 @@ +# 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 +) + +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) { + Write-Info "Target branch not provided; unable to compute diff." + return @() + } + + $shortTarget = $targetRef -replace '^refs/heads/', '' + $candidates = New-Object System.Collections.Generic.List[hashtable] + $candidates.Add(@{ Fetch = 'origin'; Ref = $shortTarget; DiffRef = "origin/$shortTarget" }) + + if ($targetRef -notmatch '^origin/' -and $shortTarget -ne $targetRef) { + $candidates.Add(@{ Fetch = 'origin'; Ref = $targetRef; DiffRef = "origin/$targetRef" }) + } + + $remotes = @() + try { + $remotes = @(git remote) + } catch { + $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 { + $repoRoot = Normalize-Path (Join-Path $PSScriptRoot '..') + $testsRoot = Join-Path $repoRoot 'StyleCop.Analyzers' + + $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 { + $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) { + $key = "$v" + $plan.plans[$key] = @{ + fullRun = $true + classes = @() + reason = 'Non-PR build' + } + } + + return $plan + } + + $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] = @{ + fullRun = $true + classes = @() + reason = 'Unable to compute diff' + } + } + + return $plan + } + + $plan.changedFiles = $changedFiles + Write-Info ("Changed files ({0})" -f $changedFilesCount) + + $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) { + $key = "$v" + $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[$key] = $entry + } + + return $plan +} + +try { + $plan = Build-Plan + + $allFull = (($plan.plans.GetEnumerator() | Where-Object { -not $_.Value.fullRun }) | Measure-Object).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) { + $key = "$v" + $fallback.plans[$key] = @{ + 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..ae84264f7 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,46 @@ 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`"") + } + + 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 +173,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: succeeded() inputs: targetPath: $(Build.SourcesDirectory)/build/OpenCover.Reports artifact: coverageResults-cs${{ parameters.LangVersion }}