diff --git a/eng/pipelines/templates/jobs/analyze.yml b/eng/pipelines/templates/jobs/analyze.yml index 4fbe04198a..d3feee17c5 100644 --- a/eng/pipelines/templates/jobs/analyze.yml +++ b/eng/pipelines/templates/jobs/analyze.yml @@ -38,6 +38,12 @@ jobs: - template: /eng/common/pipelines/templates/steps/save-package-properties.yml + - task: PowerShell@2 + displayName: Validate pipeline diagnostic parsers + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/scripts/tests/Test-Diagnostics.ps1 + # Always skip audit. Audit requires contact with restricted endpoints and is # excluded from use in internal builds. Instead, teams must check on the state # of the Audit in GitHub Actions. @@ -53,6 +59,13 @@ jobs: -Audit:$false -Deny - - template: /eng/common/pipelines/templates/steps/check-spelling.yml - parameters: - ContinueOnError: false + - ${{ if eq(variables['Build.Reason'], 'PullRequest') }}: + - template: /eng/common/pipelines/templates/steps/create-authenticated-npmrc.yml + + - task: PowerShell@2 + displayName: Check spelling (cspell) + condition: and(succeeded(), ne(variables['Skip.SpellCheck'],'true')) + inputs: + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/scripts/Check-Spelling.ps1 + arguments: -ExitWithError diff --git a/eng/scripts/Analyze-Code.ps1 b/eng/scripts/Analyze-Code.ps1 index 93ae098c42..b624abfaf8 100755 --- a/eng/scripts/Analyze-Code.ps1 +++ b/eng/scripts/Analyze-Code.ps1 @@ -63,17 +63,28 @@ $packagesToAnalyze = Get-CargoSelectedPackages ` -PackageInfoDirectory $packageInfoPath $workspaceManifestPath = [System.IO.Path]::Combine($RepoRoot, 'Cargo.toml') $packageArgs = if ($PackageName -or $ManifestDir) { - '--package ' + ($packagesToAnalyze.name -join ' --package ') + @($packagesToAnalyze.name | ForEach-Object { '--package'; $_ }) } +$packageArgsString = $packageArgs -join ' ' if ($Audit) { Invoke-LoggedCommand "cargo audit" -GroupOutput } -Invoke-LoggedCommand "cargo check --manifest-path sdk/core/azure_core/Cargo.toml $packageArgs --all-features --all-targets --keep-going" -GroupOutput +[void](Invoke-CargoCommandWithDiagnostics ` + -ArgumentList (@( + 'check', + '--manifest-path', + 'sdk/core/azure_core/Cargo.toml' + ) + $packageArgs + @( + '--all-features', + '--all-targets', + '--keep-going' + )) ` + -GroupOutput) if ($packageArgs) { - Invoke-LoggedCommand "cargo fmt --manifest-path '$workspaceManifestPath' $packageArgs -- --check" -GroupOutput + Invoke-LoggedCommand "cargo fmt --manifest-path '$workspaceManifestPath' $packageArgsString -- --check" -GroupOutput } else { Invoke-LoggedCommand "cargo fmt --manifest-path '$workspaceManifestPath' --all -- --check" -GroupOutput @@ -81,13 +92,33 @@ else { Invoke-LoggedCommand "taplo format --check" -Invoke-LoggedCommand "cargo clippy --manifest-path '$workspaceManifestPath' $packageArgs --all-features --all-targets --keep-going --no-deps" -GroupOutput +[void](Invoke-CargoCommandWithDiagnostics ` + -ArgumentList (@( + 'clippy', + '--manifest-path', + $workspaceManifestPath + ) + $packageArgs + @( + '--all-features', + '--all-targets', + '--keep-going', + '--no-deps' + )) ` + -GroupOutput) if ($Deny) { Invoke-LoggedCommand "cargo deny --manifest-path '$workspaceManifestPath' --all-features check bans licenses sources" -GroupOutput } -Invoke-LoggedCommand "cargo doc --manifest-path '$workspaceManifestPath' $packageArgs --no-deps --all-features" -GroupOutput +[void](Invoke-CargoCommandWithDiagnostics ` + -ArgumentList (@( + 'doc', + '--manifest-path', + $workspaceManifestPath + ) + $packageArgs + @( + '--no-deps', + '--all-features' + )) ` + -GroupOutput) # Verify package dependencies and keywords $verifyDependenciesScript = ([System.IO.Path]::Combine($RepoRoot, 'eng', 'scripts', 'verify-dependencies.rs')) diff --git a/eng/scripts/Build-Crates.ps1 b/eng/scripts/Build-Crates.ps1 index a7cfd5709c..9f94edafc3 100755 --- a/eng/scripts/Build-Crates.ps1 +++ b/eng/scripts/Build-Crates.ps1 @@ -29,5 +29,13 @@ else { } foreach ($path in $manifestPath) { - Invoke-LoggedCommand "cargo build --manifest-path '$path' --keep-going --all-features" -GroupOutput + [void](Invoke-CargoCommandWithDiagnostics ` + -ArgumentList @( + 'build', + '--manifest-path', + $path, + '--keep-going', + '--all-features' + ) ` + -GroupOutput) } diff --git a/eng/scripts/Check-Spelling.ps1 b/eng/scripts/Check-Spelling.ps1 new file mode 100644 index 0000000000..7207c7cf85 --- /dev/null +++ b/eng/scripts/Check-Spelling.ps1 @@ -0,0 +1,85 @@ +#!/usr/bin/env pwsh + +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +#Requires -Version 7.0 +[CmdletBinding()] +param( + [string]$CspellConfigPath = ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', '.vscode', 'cspell.json')), + [string]$SpellCheckRoot = ([System.IO.Path]::Combine($PSScriptRoot, '..', '..')), + [switch]$ExitWithError, + [string]$SourceCommittish = $env:SYSTEM_PULLREQUEST_SOURCECOMMITID, + [string]$TargetCommittish = ("origin/$($env:SYSTEM_PULLREQUEST_TARGETBRANCH)" -replace 'refs/heads/') +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +. ([System.IO.Path]::Combine($PSScriptRoot, '..', 'common', 'scripts', 'common.ps1')) +. ([System.IO.Path]::Combine($PSScriptRoot, 'shared', 'common.ps1')) + +if (!(Test-Path -Path $CspellConfigPath -PathType Leaf)) { + Write-PipelineIssue -Type error -Message "Could not locate CSpell config file '$CspellConfigPath'." + exit 1 +} + +$getChangedFilesScript = ([System.IO.Path]::Combine($PSScriptRoot, '..', 'common', 'scripts', 'get-changedfiles.ps1')) +$invokeCspellScript = ([System.IO.Path]::Combine($PSScriptRoot, '..', 'common', 'spelling', 'Invoke-Cspell.ps1')) +$changedFiles = @( + & $getChangedFilesScript ` + -SourceCommittish $SourceCommittish ` + -TargetCommittish $TargetCommittish | + ForEach-Object { Resolve-Path -Path $_ } +) + +Write-Host "Git detected $($changedFiles.Count) changed file(s). Files checked by CSpell may exclude files according to cspell.json." +if ($changedFiles.Count -eq 0) { + Write-Host 'No changes detected.' + exit 0 +} + +$spellingOutput = @( + & $invokeCspellScript ` + -CSpellConfigPath $CspellConfigPath ` + -SpellCheckRoot $SpellCheckRoot ` + -FileList $changedFiles.Path +) +$cspellExitCode = $LASTEXITCODE +$issueBudget = New-PipelineIssueBudget +$parsedIssues = 0 + +foreach ($line in $spellingOutput) { + $text = "$line" + Write-Host $text + $issue = ConvertFrom-CSpellIssue $text + if ($issue) { + Write-BudgetedPipelineIssue ` + -Budget $issueBudget ` + -Type $(if ($ExitWithError) { 'error' } else { 'warning' }) ` + -Message $issue.Message ` + -SourcePath $issue.SourcePath ` + -LineNumber $issue.LineNumber ` + -ColumnNumber $issue.ColumnNumber ` + -Code 'cspell' + $parsedIssues++ + } +} + +Complete-PipelineIssueBudget $issueBudget + +if ($parsedIssues -gt 0) { + Write-Host 'Spelling errors detected. To correct false positives or learn about spell checking, see https://aka.ms/azsdk/engsys/spellcheck.' + if ($ExitWithError) { + exit 1 + } +} +elseif ($cspellExitCode -ne 0) { + Write-PipelineIssue -Type error -Message "CSpell exited with code $cspellExitCode. This may indicate a configuration or tool failure." + exit $cspellExitCode +} +else { + Write-Host 'No spelling errors detected.' +} + +exit 0 diff --git a/eng/scripts/Test-Packages.ps1 b/eng/scripts/Test-Packages.ps1 index 6c516a5e97..1955742f4b 100755 --- a/eng/scripts/Test-Packages.ps1 +++ b/eng/scripts/Test-Packages.ps1 @@ -22,36 +22,43 @@ $cargoFeatureArgs = if ($FeatureSet -eq 'All') { @('--all-features') } else { @( # Helper function to run cargo test, capturing JSON output only when the active # toolchain supports `--format json -Z unstable-options`. function Invoke-CargoTest ( - [string]$TestParams, + [string[]]$TestParams, [string]$PackageName, [string]$ManifestPath, - [string]$OutputFile + [string]$OutputFile, + [switch]$DisableJsonOutput ) { Write-Host "Running tests for $PackageName" - $commandParts = @('cargo', 'test', $TestParams, '--manifest-path', $ManifestPath) + $cargoFeatureArgs + @('--no-fail-fast') - $command = $commandParts -join ' ' + $commandArgs = @('test') + $TestParams + @('--manifest-path', $ManifestPath) + $cargoFeatureArgs + @('--no-fail-fast') + $captureJson = $usesJsonTestOutput -and !$DisableJsonOutput - if ($usesJsonTestOutput) { - $result = Invoke-LoggedCommand ` - "$command -- --format json -Z unstable-options" ` + if ($captureJson) { + $result = Invoke-CargoCommandWithDiagnostics ` + -ArgumentList ($commandArgs + @('--', '--format', 'json', '-Z', 'unstable-options')) ` -GroupOutput ` - -DoNotExitOnFailedExitCode + -DoNotExitOnFailedExitCode ` + -ParseJsonTestOutput ` + -TestOutputFile $OutputFile LogGroupStart 'Test result JSON' - $result | Tee-Object -FilePath $OutputFile + Get-Content $OutputFile | Write-Host LogGroupEnd } else { - Invoke-LoggedCommand $command -GroupOutput -DoNotExitOnFailedExitCode + $result = Invoke-CargoCommandWithDiagnostics ` + -ArgumentList $commandArgs ` + -GroupOutput ` + -DoNotExitOnFailedExitCode ` + -ParseHumanTestOutput } - if ($LASTEXITCODE) { + if ($result.ExitCode) { $message = "Tests failed for $PackageName." - if ($usesJsonTestOutput) { + if ($captureJson) { $message += " For more information see the pipeline Tests tab." } - LogError $message - exit $LASTEXITCODE + Write-Host $message + exit $result.ExitCode } } @@ -113,8 +120,9 @@ foreach ($package in $packagesToTest) { Write-Host "`n`nTesting package: '$($package.Name)'`n" - $buildCommand = (@('cargo', 'build') + $cargoFeatureArgs + @('--keep-going')) -join ' ' - Invoke-LoggedCommand $buildCommand -GroupOutput + [void](Invoke-CargoCommandWithDiagnostics ` + -ArgumentList (@('build') + $cargoFeatureArgs + @('--keep-going')) ` + -GroupOutput) Write-Host "`n`n" $manifestPath = [System.IO.Path]::Combine($packageDirectory, 'Cargo.toml') @@ -122,20 +130,23 @@ foreach ($package in $packagesToTest) { $docTestOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-doctest-$timestamp.json")) Invoke-CargoTest ` - -TestParams "--doc" ` + -TestParams @('--doc') ` -PackageName $package.Name ` -ManifestPath $manifestPath ` -OutputFile $docTestOutput $allTargetsOutput = ([System.IO.Path]::Combine($testResultsDir, "$($package.Name)-alltargets-$timestamp.json")) Invoke-CargoTest ` - -TestParams "--lib --bins --tests --examples" ` + -TestParams @('--lib', '--bins', '--tests', '--examples') ` -PackageName $package.Name ` -ManifestPath $manifestPath ` -OutputFile $allTargetsOutput - $benchCommand = (@('cargo', 'test', '--benches', '--manifest-path', $manifestPath) + $cargoFeatureArgs + @('--no-fail-fast')) -join ' ' - Invoke-LoggedCommand $benchCommand -GroupOutput + Invoke-CargoTest ` + -TestParams @('--benches') ` + -PackageName $package.Name ` + -ManifestPath $manifestPath ` + -DisableJsonOutput $cleanupScript = ([System.IO.Path]::Combine($packageDirectory, 'Test-Cleanup.ps1')) if (Test-Path $cleanupScript) { diff --git a/eng/scripts/shared/Cargo.ps1 b/eng/scripts/shared/Cargo.ps1 index 79e1151243..c759d807b8 100644 --- a/eng/scripts/shared/Cargo.ps1 +++ b/eng/scripts/shared/Cargo.ps1 @@ -2,6 +2,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +if (!(Get-Command New-PipelineIssueBudget -ErrorAction SilentlyContinue)) { + . ([System.IO.Path]::Combine($PSScriptRoot, 'Diagnostics.ps1')) +} + function Get-ActiveRustToolchain( [string]$ExecutePath ) { @@ -31,6 +35,256 @@ function Test-IsNightlyRustToolchain( return (Get-ResolvedRustToolchain -Toolchain $Toolchain -ExecutePath $ExecutePath) -match '^nightly(?:$|[-])' } +function Get-CargoArgumentsWithJsonMessages([string[]]$ArgumentList) { + if ($ArgumentList -match '^--message-format(?:=|$)') { + return $ArgumentList + } + + $separatorIndex = [Array]::IndexOf($ArgumentList, '--') + if ($separatorIndex -lt 0) { + return @($ArgumentList) + @('--message-format=json') + } + + return @($ArgumentList[0..($separatorIndex - 1)]) + + @('--message-format=json') + + @($ArgumentList[$separatorIndex..($ArgumentList.Count - 1)]) +} + +function Get-JsonPropertyValue( + $Object, + [string]$Name +) { + if (!$Object) { + return $null + } + + $property = $Object.PSObject.Properties[$Name] + if ($property) { + return $property.Value + } + return $null +} + +function Get-RustTestFailuresFromOutput([string[]]$Output) { + $failures = @() + $name = $null + $details = [System.Collections.Generic.List[string]]::new() + + foreach ($line in $Output) { + if ($line -match '^---- (.+) stdout ----$') { + if ($name) { + $failures += [pscustomobject]@{ + Name = $name + Output = ($details -join [Environment]::NewLine).Trim() + } + } + $name = $Matches[1] + $details.Clear() + continue + } + + if ($name -and $line -eq 'failures:') { + $failures += [pscustomobject]@{ + Name = $name + Output = ($details -join [Environment]::NewLine).Trim() + } + $name = $null + $details.Clear() + continue + } + + if ($name) { + $details.Add($line) + } + } + + if ($name) { + $failures += [pscustomobject]@{ + Name = $name + Output = ($details -join [Environment]::NewLine).Trim() + } + } + + return $failures +} + +function Write-CargoCompilerDiagnostic( + $CargoMessage, + $IssueBudget +) { + $diagnostic = Get-JsonPropertyValue $CargoMessage 'message' + $renderedValue = Get-JsonPropertyValue $diagnostic 'rendered' + $messageValue = Get-JsonPropertyValue $diagnostic 'message' + $rendered = if ($renderedValue) { "$renderedValue".TrimEnd() } else { "$messageValue" } + Write-Host $rendered + + if ((Get-JsonPropertyValue $diagnostic 'level') -ne 'error') { + return $false + } + + $spans = @(Get-JsonPropertyValue $diagnostic 'spans') + $primarySpan = @($spans | Where-Object { Get-JsonPropertyValue $_ 'is_primary' } | Select-Object -First 1) + $sourcePath = $null + $lineNumber = 0 + $columnNumber = 0 + if ($primarySpan.Count -gt 0) { + $sourcePath = Get-JsonPropertyValue $primarySpan[0] 'file_name' + $lineNumber = Get-JsonPropertyValue $primarySpan[0] 'line_start' + $columnNumber = Get-JsonPropertyValue $primarySpan[0] 'column_start' + } + + $diagnosticCode = Get-JsonPropertyValue $diagnostic 'code' + $code = Get-JsonPropertyValue $diagnosticCode 'code' + Write-BudgetedPipelineIssue ` + -Budget $IssueBudget ` + -Type error ` + -Message $rendered ` + -SourcePath $sourcePath ` + -LineNumber $lineNumber ` + -ColumnNumber $columnNumber ` + -Code $code + return $true +} + +function Write-RustTestFailure( + [string]$Name, + [string]$Output, + $IssueBudget +) { + $message = "Test '$Name' failed." + if ($Output) { + $message += [Environment]::NewLine + $Output.Trim() + } + Write-BudgetedPipelineIssue -Budget $IssueBudget -Type error -Message $message +} + +function Write-RustJsonTestEvent( + $TestEvent, + $IssueBudget +) { + if ((Get-JsonPropertyValue $TestEvent 'type') -ne 'test' -or (Get-JsonPropertyValue $TestEvent 'event') -ne 'failed') { + return $false + } + + $testOutput = @( + Get-JsonPropertyValue $TestEvent 'message' + Get-JsonPropertyValue $TestEvent 'reason' + Get-JsonPropertyValue $TestEvent 'stdout' + ) | + Where-Object { $_ } | + ForEach-Object { "$_".Trim() } + Write-RustTestFailure ` + -Name (Get-JsonPropertyValue $TestEvent 'name') ` + -Output ($testOutput -join [Environment]::NewLine) ` + -IssueBudget $IssueBudget + return $true +} + +function Invoke-CargoCommandWithDiagnostics { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string[]]$ArgumentList, + [switch]$GroupOutput, + [switch]$DoNotExitOnFailedExitCode, + [switch]$ParseJsonTestOutput, + [switch]$ParseHumanTestOutput, + [string]$TestOutputFile, + [int]$MaximumIssues = 50 + ) + + $cargoArguments = Get-CargoArgumentsWithJsonMessages $ArgumentList + $command = "cargo $($cargoArguments -join ' ')" + $startTime = Get-Date + $issueBudget = New-PipelineIssueBudget -Maximum $MaximumIssues + $humanTestOutput = [System.Collections.Generic.List[string]]::new() + $jsonTestOutput = [System.Collections.Generic.List[string]]::new() + + if ($GroupOutput) { + LogGroupStart $command + } + else { + Write-Host "> $command" + } + + try { + & cargo @cargoArguments 2>&1 | ForEach-Object { + $line = "$_" + $json = $null + if ($line.TrimStart().StartsWith('{')) { + try { + $json = $line | ConvertFrom-Json -Depth 100 -ErrorAction Stop + } + catch { + $json = $null + } + } + + $reason = Get-JsonPropertyValue $json 'reason' + $type = Get-JsonPropertyValue $json 'type' + $event = Get-JsonPropertyValue $json 'event' + if ($reason -eq 'compiler-message') { + [void](Write-CargoCompilerDiagnostic -CargoMessage $json -IssueBudget $issueBudget) + } + elseif ($ParseJsonTestOutput -and $type -and $event) { + $jsonTestOutput.Add($line) + [void](Write-RustJsonTestEvent -TestEvent $json -IssueBudget $issueBudget) + } + elseif ($reason) { + # Cargo artifact and build-script records are intentionally omitted. + } + else { + Write-Host $line + if ($ParseHumanTestOutput) { + $humanTestOutput.Add($line) + } + } + } + $exitCode = $LASTEXITCODE + } + finally { + if ($GroupOutput) { + LogGroupEnd + } + } + + if ($TestOutputFile) { + [System.IO.File]::WriteAllLines($TestOutputFile, $jsonTestOutput) + } + + if ($exitCode -ne 0 -and $ParseHumanTestOutput) { + foreach ($failure in (Get-RustTestFailuresFromOutput $humanTestOutput)) { + Write-RustTestFailure -Name $failure.Name -Output $failure.Output -IssueBudget $issueBudget + } + } + + $duration = (Get-Date) - $startTime + if ($exitCode -ne 0) { + if ($issueBudget.Emitted -eq 0) { + Write-BudgetedPipelineIssue ` + -Budget $issueBudget ` + -Type error ` + -Message "Command failed to execute ($duration): $command" + } + Write-Host "Command failed to execute ($duration): $command" + } + else { + Write-Host "Command succeeded ($duration)`n" + } + + Complete-PipelineIssueBudget $issueBudget + + if ($exitCode -ne 0 -and !$DoNotExitOnFailedExitCode) { + exit $exitCode + } + + return [pscustomobject]@{ + ExitCode = $exitCode + IssueCount = $issueBudget.Emitted + SuppressedIssueCount = $issueBudget.Suppressed + } +} + function Get-CargoMetadata() { cargo metadata --no-deps --format-version 1 --manifest-path "$RepoRoot/Cargo.toml" | ConvertFrom-Json -Depth 100 -AsHashtable } diff --git a/eng/scripts/shared/Diagnostics.ps1 b/eng/scripts/shared/Diagnostics.ps1 new file mode 100644 index 0000000000..bfda114c40 --- /dev/null +++ b/eng/scripts/shared/Diagnostics.ps1 @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +function ConvertTo-AzDevOpsLoggingValue( + [string]$Value, + [switch]$Property +) { + $escaped = $Value.Replace('%', '%AZP25').Replace("`r", '%0D').Replace("`n", '%0A') + if ($Property) { + $escaped = $escaped.Replace(';', '%3B').Replace(']', '%5D') + } + return $escaped +} + +function ConvertTo-GitHubLoggingValue( + [string]$Value, + [switch]$Property +) { + $escaped = $Value.Replace('%', '%25').Replace("`r", '%0D').Replace("`n", '%0A') + if ($Property) { + $escaped = $escaped.Replace(':', '%3A').Replace(',', '%2C') + } + return $escaped +} + +function Get-RepositoryRelativePath([string]$Path) { + if (!$Path) { + return $null + } + + try { + $fullPath = if ([System.IO.Path]::IsPathRooted($Path)) { + [System.IO.Path]::GetFullPath($Path) + } + else { + [System.IO.Path]::GetFullPath(([System.IO.Path]::Combine($RepoRoot, $Path))) + } + $relativePath = [System.IO.Path]::GetRelativePath( + [System.IO.Path]::GetFullPath($RepoRoot), + $fullPath + ) + + if ([System.IO.Path]::IsPathRooted($relativePath) -or $relativePath -eq '..' -or $relativePath.StartsWith("../") -or $relativePath.StartsWith("..\")) + { + return $null + } + + return $relativePath.Replace('\', '/') + } + catch { + return $null + } +} + +function ConvertFrom-CSpellIssue([string]$Line) { + if ($Line -notmatch '^(?.+):(?\d+):(?\d+)\s+-\s+(?.+)$') { + return $null + } + + return [pscustomobject]@{ + SourcePath = $Matches.file + LineNumber = [int]$Matches.line + ColumnNumber = [int]$Matches.column + Message = $Matches.message + } +} + +function Write-PipelineIssue( + [ValidateSet('error', 'warning')] + [string]$Type, + [string]$Message, + [string]$SourcePath, + [int]$LineNumber, + [int]$ColumnNumber, + [string]$Code +) { + $relativePath = Get-RepositoryRelativePath $SourcePath + + if (Test-SupportsDevOpsLogging) { + $properties = "type=$Type;" + if ($relativePath) { + $properties += "sourcepath=$(ConvertTo-AzDevOpsLoggingValue $relativePath -Property);" + if ($LineNumber -gt 0) { + $properties += "linenumber=$LineNumber;" + } + if ($ColumnNumber -gt 0) { + $properties += "columnnumber=$ColumnNumber;" + } + } + if ($Code) { + $properties += "code=$(ConvertTo-AzDevOpsLoggingValue $Code -Property);" + } + + Write-Host "##vso[task.logissue $properties]$(ConvertTo-AzDevOpsLoggingValue $Message)" + } + elseif (Test-SupportsGitHubLogging) { + $properties = @() + if ($relativePath) { + $properties += "file=$(ConvertTo-GitHubLoggingValue $relativePath -Property)" + if ($LineNumber -gt 0) { + $properties += "line=$LineNumber" + } + if ($ColumnNumber -gt 0) { + $properties += "col=$ColumnNumber" + } + } + if ($Code) { + $properties += "title=$(ConvertTo-GitHubLoggingValue $Code -Property)" + } + + $propertyText = if ($properties.Count -gt 0) { " $($properties -join ',')" } else { '' } + Write-Host "::$Type$propertyText::$(ConvertTo-GitHubLoggingValue $Message)" + } + elseif ($Type -eq 'error') { + if ($relativePath) { + Write-Host "[$relativePath`:$LineNumber`:$ColumnNumber] $Message" -ForegroundColor Red + } + else { + Write-Host $Message -ForegroundColor Red + } + } + else { + if ($relativePath) { + Write-Host "[$relativePath`:$LineNumber`:$ColumnNumber] $Message" -ForegroundColor Yellow + } + else { + Write-Host $Message -ForegroundColor Yellow + } + } +} + +function New-PipelineIssueBudget([int]$Maximum = 50) { + return [pscustomobject]@{ + Maximum = $Maximum + Emitted = 0 + Suppressed = 0 + } +} + +function Write-BudgetedPipelineIssue( + $Budget, + [ValidateSet('error', 'warning')] + [string]$Type, + [string]$Message, + [string]$SourcePath, + [int]$LineNumber, + [int]$ColumnNumber, + [string]$Code +) { + if ($Budget.Emitted -ge $Budget.Maximum) { + $Budget.Suppressed++ + return + } + + Write-PipelineIssue ` + -Type $Type ` + -Message $Message ` + -SourcePath $SourcePath ` + -LineNumber $LineNumber ` + -ColumnNumber $ColumnNumber ` + -Code $Code + $Budget.Emitted++ +} + +function Complete-PipelineIssueBudget($Budget) { + if ($Budget.Suppressed -gt 0) { + Write-Host "Suppressed $($Budget.Suppressed) additional pipeline issue(s) after reaching the $($Budget.Maximum)-issue limit. See the task log for complete output." + } +} diff --git a/eng/scripts/shared/common.ps1 b/eng/scripts/shared/common.ps1 index 0f60880e33..adaf832153 100644 --- a/eng/scripts/shared/common.ps1 +++ b/eng/scripts/shared/common.ps1 @@ -2,4 +2,5 @@ # Licensed under the MIT License. . ([System.IO.Path]::Combine($PSScriptRoot, 'Process.ps1')) +. ([System.IO.Path]::Combine($PSScriptRoot, 'Diagnostics.ps1')) . ([System.IO.Path]::Combine($PSScriptRoot, 'Cargo.ps1')) diff --git a/eng/scripts/tests/Test-Diagnostics.ps1 b/eng/scripts/tests/Test-Diagnostics.ps1 new file mode 100644 index 0000000000..34a0fefb48 --- /dev/null +++ b/eng/scripts/tests/Test-Diagnostics.ps1 @@ -0,0 +1,151 @@ +#!/usr/bin/env pwsh + +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2.0 + +$global:RepoRoot = [System.IO.Path]::GetFullPath(([System.IO.Path]::Combine($PSScriptRoot, '..', '..', '..'))) +. ([System.IO.Path]::Combine($RepoRoot, 'eng', 'common', 'scripts', 'logging.ps1')) +. ([System.IO.Path]::Combine($RepoRoot, 'eng', 'scripts', 'shared', 'Diagnostics.ps1')) +. ([System.IO.Path]::Combine($RepoRoot, 'eng', 'scripts', 'shared', 'Cargo.ps1')) + +function Assert-Equal($Expected, $Actual, [string]$Description) { + if ($Expected -ne $Actual) { + throw "$Description`nExpected: $Expected`nActual: $Actual" + } +} + +function Assert-Contains([string]$Expected, [string[]]$Actual, [string]$Description) { + if (!($Actual | Where-Object { "$_".Contains($Expected) })) { + throw "$Description`nExpected output containing: $Expected`nActual output:`n$($Actual -join [Environment]::NewLine)" + } +} + +Assert-Equal ` + '100%AZP25%3B%5D%0D%0A' ` + (ConvertTo-AzDevOpsLoggingValue "100%;]`r`n" -Property) ` + 'Azure DevOps properties should be escaped.' + +$cargoArgs = Get-CargoArgumentsWithJsonMessages @('test', '--all-features', '--', '--nocapture') +Assert-Equal ` + 'test --all-features --message-format=json -- --nocapture' ` + ($cargoArgs -join ' ') ` + 'Cargo JSON message format should be inserted before test-binary arguments.' + +$humanFailures = @(Get-RustTestFailuresFromOutput @( + 'running 1 test', + 'test tests::fails ... FAILED', + '', + 'failures:', + '', + '---- tests::fails stdout ----', + 'thread ''tests::fails'' panicked at src/lib.rs:10:5:', + 'assertion failed', + '', + 'failures:', + ' tests::fails' + )) +Assert-Equal 1 $humanFailures.Count 'One human-format test failure should be parsed.' +Assert-Equal 'tests::fails' $humanFailures[0].Name 'The failed test name should be parsed.' +Assert-Contains 'assertion failed' @($humanFailures[0].Output) 'The failed test output should be retained.' + +$unixSpellingIssue = ConvertFrom-CSpellIssue 'sdk/example.rs:8:14 - Unknown word (azur)' +Assert-Equal 'sdk/example.rs' $unixSpellingIssue.SourcePath 'A Unix CSpell path should be parsed.' +Assert-Equal 8 $unixSpellingIssue.LineNumber 'A CSpell line should be parsed.' +Assert-Equal 14 $unixSpellingIssue.ColumnNumber 'A CSpell column should be parsed.' + +$windowsSpellingIssue = ConvertFrom-CSpellIssue 'C:\agent\_work\1\s\sdk\example.rs:9:3 - Unknown word (azur)' +Assert-Equal 'C:\agent\_work\1\s\sdk\example.rs' $windowsSpellingIssue.SourcePath 'A Windows CSpell path should be parsed.' + +$oldTeamProjectId = $env:SYSTEM_TEAMPROJECTID +try { + $env:SYSTEM_TEAMPROJECTID = 'test' + $issueOutput = @( + (& { + Write-PipelineIssue ` + -Type error ` + -Message "error[E0001]`nhelp: fix 100%" ` + -SourcePath ([System.IO.Path]::Combine($RepoRoot, 'sdk', 'example.rs')) ` + -LineNumber 12 ` + -ColumnNumber 7 ` + -Code 'E0001' + } 6>&1) | ForEach-Object { "$_" } + ) + + Assert-Equal 1 $issueOutput.Count 'One Azure DevOps logging command should be emitted.' + Assert-Contains 'sourcepath=sdk/example.rs;' $issueOutput 'The repository-relative source path should be included.' + Assert-Contains 'linenumber=12;columnnumber=7;code=E0001;' $issueOutput 'The source location and diagnostic code should be included.' + Assert-Contains 'error[E0001]%0Ahelp: fix 100%AZP25' $issueOutput 'The multiline diagnostic body should be escaped.' + + $budget = New-PipelineIssueBudget -Maximum 2 + $budgetOutput = @( + (& { + 1..3 | ForEach-Object { + Write-BudgetedPipelineIssue -Budget $budget -Type error -Message "failure $_" + } + Complete-PipelineIssueBudget $budget + } 6>&1) | ForEach-Object { "$_" } + ) + + Assert-Equal 2 $budget.Emitted 'The issue budget should emit only its maximum.' + Assert-Equal 1 $budget.Suppressed 'The issue budget should count suppressed issues.' + Assert-Contains 'Suppressed 1 additional pipeline issue' $budgetOutput 'Suppressed issues should be summarized.' + + $cargoMessage = @' +{ + "reason": "compiler-message", + "message": { + "rendered": "error[E0308]: mismatched types\n --> sdk/example.rs:4:9\nhelp: use the expected type\n", + "message": "mismatched types", + "level": "error", + "code": { "code": "E0308" }, + "spans": [ + { + "file_name": "sdk/example.rs", + "line_start": 4, + "column_start": 9, + "is_primary": true + } + ] + } +} +'@ | ConvertFrom-Json -Depth 100 + $cargoBudget = New-PipelineIssueBudget + $cargoOutput = @( + (& { + [void](Write-CargoCompilerDiagnostic -CargoMessage $cargoMessage -IssueBudget $cargoBudget) + } 6>&1) | ForEach-Object { "$_" } + ) + + Assert-Equal 1 $cargoBudget.Emitted 'An error-level Cargo diagnostic should emit one issue.' + Assert-Contains 'sourcepath=sdk/example.rs;linenumber=4;columnnumber=9;code=E0308;' $cargoOutput 'Cargo spans should map to issue metadata.' + Assert-Contains 'help: use the expected type' $cargoOutput 'The complete rendered Cargo diagnostic should be retained.' + + $testEvent = @' +{ + "type": "test", + "event": "failed", + "name": "tests::fails", + "stdout": "thread 'tests::fails' panicked at src/lib.rs:10:5:\nassertion failed" +} +'@ | ConvertFrom-Json + $testBudget = New-PipelineIssueBudget + $testOutput = @( + (& { + [void](Write-RustJsonTestEvent -TestEvent $testEvent -IssueBudget $testBudget) + } 6>&1) | ForEach-Object { "$_" } + ) + + Assert-Equal 1 $testBudget.Emitted 'A failed JSON test event should emit one issue.' + Assert-Contains "Test 'tests::fails' failed." $testOutput 'The failed test name should be included.' + Assert-Contains 'assertion failed' $testOutput 'Captured test output should be included.' +} +finally { + $env:SYSTEM_TEAMPROJECTID = $oldTeamProjectId +} + +Write-Host 'Diagnostic parser tests passed.'