diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml
index 127a70f19..1887b7ac8 100644
--- a/.github/workflows/performance.yml
+++ b/.github/workflows/performance.yml
@@ -2,6 +2,12 @@ name: Compare ScriptAnalyzer performance
on:
workflow_dispatch:
+ inputs:
+ repetitions:
+ description: Number of fresh-process cold/warm pairs per build
+ type: choice
+ options: ['3', '5', '10']
+ default: '3'
permissions:
contents: read
@@ -14,7 +20,25 @@ jobs:
outputs:
powershell: ${{ steps.revision.outputs.sha }}
semver: ${{ steps.semver-revision.outputs.sha }}
+ upstream: ${{ steps.analyzer-revisions.outputs.upstream }}
+ fork: ${{ steps.analyzer-revisions.outputs.fork }}
steps:
+ - name: Pin all analyzer revisions once
+ id: analyzer-revisions
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ foreach ($source in @(
+ @{ Name = 'upstream'; Repository = 'PowerShell/PSScriptAnalyzer'; Ref = 'main' },
+ @{ Name = 'fork'; Repository = 'jessehouwing/PSScriptAnalyzer'; Ref = 'main' }
+ )) {
+ $revision = git ls-remote "https://github.com/$($source.Repository).git" "refs/heads/$($source.Ref)"
+ if ($LASTEXITCODE -ne 0 -or $revision -notmatch '^([0-9a-f]{40})\s') {
+ throw "Cannot resolve $($source.Repository)@$($source.Ref)."
+ }
+ "$($source.Name)=$($Matches[1])" >> $env:GITHUB_OUTPUT
+ }
+
- name: Checkout PowerShell source tree once for both builds
uses: actions/checkout@v4
with:
@@ -91,6 +115,8 @@ jobs:
BENCHMARK_REPOSITORY: ${{ matrix.repository }}
BENCHMARK_WORKLOAD: ${{ matrix.workload }}
WORKLOAD_REVISION: ${{ needs.workload.outputs[matrix.workload] }}
+ ANALYZER_REVISION: ${{ needs.workload.outputs[matrix.source] }}
+ BENCHMARK_REPETITIONS: ${{ inputs.repetitions }}
steps:
- name: Checkout benchmark harness
uses: actions/checkout@v4
@@ -98,11 +124,11 @@ jobs:
path: harness
persist-credentials: false
- - name: Checkout analyzer main
+ - name: Checkout pinned analyzer revision
uses: actions/checkout@v4
with:
repository: ${{ matrix.repository }}
- ref: main
+ ref: ${{ needs.workload.outputs[matrix.source] }}
path: analyzer
persist-credentials: false
@@ -127,7 +153,7 @@ jobs:
working-directory: analyzer
run: '& "$env:GITHUB_WORKSPACE/analyzer/build.ps1" -Configuration Release -PSVersion 7 -Verbose'
- - name: Measure cold and warm analysis in a fresh shell
+ - name: Measure repeated cold and warm analysis in fresh shells
run: |
$ErrorActionPreference = 'Stop'
$workspace = $env:GITHUB_WORKSPACE
@@ -148,15 +174,10 @@ jobs:
if ($manifests.Count -ne 1) {
throw "Expected exactly one built manifest, found $($manifests.Count)."
}
- $resultPath = Join-Path $resultDirectory "$env:BENCHMARK_SOURCE.json"
- & $pwsh -NoLogo -NoProfile -NonInteractive -File $measureScript `
- -ModulePath $manifests[0].FullName -ScriptPath $scriptPath -ResultPath $resultPath @analysisArguments
- if ($LASTEXITCODE -ne 0) {
- throw "Benchmark failed (exit code $LASTEXITCODE)."
- }
- $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
$revision = git -C $sourcePath rev-parse HEAD
- if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve analyzer revision.' }
+ if ($LASTEXITCODE -ne 0 -or $revision -ne $env:ANALYZER_REVISION) {
+ throw 'Analyzer checkout does not match the pinned revision.'
+ }
$metadata = @{
Source = $env:BENCHMARK_SOURCE
Repository = $env:BENCHMARK_REPOSITORY
@@ -169,16 +190,43 @@ jobs:
RunnerImageVersion = $env:ImageVersion
ProcessorCount = [Environment]::ProcessorCount
}
- $result | Add-Member -NotePropertyMembers $metadata
- $result | ConvertTo-Json | Set-Content -LiteralPath $resultPath -Encoding utf8
- $result | Format-List
+ for ($sample = 1; $sample -le [int]$env:BENCHMARK_REPETITIONS; $sample++) {
+ $resultPath = Join-Path $resultDirectory "$env:BENCHMARK_SOURCE-$sample.json"
+ $attempt = 0
+ # Failed upstream runs are retried in entirely fresh processes.
+ # The job timeout bounds retries if upstream never succeeds.
+ $PSNativeCommandUseErrorActionPreference = $false
+ do {
+ $attempt++
+ $attemptLog = Join-Path $resultDirectory "$env:BENCHMARK_SOURCE-$sample-attempt-$attempt.log"
+ & $pwsh -NoLogo -NoProfile -NonInteractive -File $measureScript `
+ -ModulePath $manifests[0].FullName -ScriptPath $scriptPath -ResultPath $resultPath @analysisArguments 2>&1 |
+ Tee-Object -FilePath $attemptLog
+ $attemptExitCode = $LASTEXITCODE
+ if ($attemptExitCode -ne 0) {
+ if ($env:BENCHMARK_SOURCE -ne 'upstream') {
+ throw "Benchmark sample $sample failed (exit code $attemptExitCode)."
+ }
+ Write-Warning "Upstream sample $sample attempt $attempt failed (exit code $attemptExitCode); retrying in a fresh process."
+ }
+ } while ($attemptExitCode -ne 0)
+ $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
+ $result | Add-Member -NotePropertyMembers $metadata
+ $result | Add-Member -NotePropertyName Sample -NotePropertyValue $sample
+ $result | Add-Member -NotePropertyName Attempts -NotePropertyValue $attempt
+ $result | Add-Member -NotePropertyName Inconsistent -NotePropertyValue ($attempt -gt 1)
+ $result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $resultPath -Encoding utf8
+ $result | Format-List
+ }
- name: Upload raw benchmark results
if: always()
uses: actions/upload-artifact@v4
with:
name: scriptanalyzer-performance-${{ matrix.os }}-${{ matrix.workload }}-${{ matrix.source }}
- path: results/*.json
+ path: |
+ results/*.json
+ results/*.log
if-no-files-found: warn
compare:
@@ -187,6 +235,8 @@ jobs:
if: ${{ !cancelled() }}
runs-on: ${{ matrix.os }}
timeout-minutes: 10
+ env:
+ BENCHMARK_REPETITIONS: ${{ inputs.repetitions }}
strategy:
fail-fast: false
matrix:
@@ -205,49 +255,88 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
$resultDirectory = Join-Path $env:GITHUB_WORKSPACE 'results'
- $baseline = Get-Content -LiteralPath "$resultDirectory/upstream.json" -Raw | ConvertFrom-Json
- $candidate = Get-Content -LiteralPath "$resultDirectory/fork.json" -Raw | ConvertFrom-Json
- if ($baseline.InputSHA256 -ne $candidate.InputSHA256 -or
- $baseline.InputFileCount -ne $candidate.InputFileCount -or
- $baseline.SettingsSHA256 -ne $candidate.SettingsSHA256 -or
- $baseline.Recurse -ne $candidate.Recurse -or
- $baseline.WorkloadRevision -ne $candidate.WorkloadRevision -or
- $baseline.PowerShellVersion -ne $candidate.PowerShellVersion -or
- $baseline.RunnerOS -ne $candidate.RunnerOS) {
- throw 'The benchmark input, settings or runtime differ; results are not comparable.'
+ $measurements = @{}
+ foreach ($source in 'upstream', 'fork') {
+ $measurements[$source] = @(for ($sample = 1; $sample -le [int]$env:BENCHMARK_REPETITIONS; $sample++) {
+ Get-Content -LiteralPath "$resultDirectory/$source-$sample.json" -Raw | ConvertFrom-Json
+ })
+ }
+ $baseline = $measurements.upstream[0]
+ $findingsMatch = $baseline.ColdDiagnosticCount -gt 0
+ foreach ($source in 'upstream', 'fork') {
+ foreach ($candidate in $measurements[$source]) {
+ foreach ($property in 'InputSHA256', 'InputFileCount', 'SettingsSHA256', 'Recurse',
+ 'WorkloadRevision', 'PowerShellVersion', 'RunnerOS', 'Culture', 'UICulture') {
+ if ($baseline.$property -cne $candidate.$property) {
+ throw "Benchmark property '$property' differs; results are not comparable."
+ }
+ }
+ if ($candidate.Revision -ne $measurements[$source][0].Revision) {
+ throw "Analyzer revision changed between $source samples."
+ }
+ foreach ($run in 'Cold', 'Warm') {
+ if ($candidate."${run}DiagnosticCount" -ne $baseline.ColdDiagnosticCount -or
+ $candidate."${run}DiagnosticsSHA256" -cne $baseline.ColdDiagnosticsSHA256) {
+ $findingsMatch = $false
+ }
+ }
+ }
}
- $findingsMatch = $baseline.ColdDiagnosticCount -gt 0 -and
- $baseline.ColdDiagnosticCount -eq $candidate.ColdDiagnosticCount -and
- $baseline.ColdDiagnosticCount -eq $baseline.WarmDiagnosticCount -and
- $baseline.ColdDiagnosticCount -eq $candidate.WarmDiagnosticCount
$report = [ordered]@{
WorkloadRepository = $baseline.WorkloadRepository
WorkloadRevision = $baseline.WorkloadRevision
ExpectedDiagnosticCount = $baseline.ColdDiagnosticCount
FindingsVerified = $findingsMatch
- Measurements = @($baseline, $candidate)
+ UpstreamInconsistent = @($measurements.upstream | Where-Object Inconsistent).Count -gt 0
+ Measurements = @($measurements.upstream) + @($measurements.fork)
+ }
+ $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath "$resultDirectory/comparison.json" -Encoding utf8
+
+ function Get-Median($values) {
+ $sorted = @($values | Sort-Object)
+ $middle = [int][Math]::Floor($sorted.Count / 2)
+ if ($sorted.Count % 2) { return $sorted[$middle] }
+ return ($sorted[$middle - 1] + $sorted[$middle]) / 2
+ }
+ $medians = @{}
+ foreach ($source in 'upstream', 'fork') {
+ $medians[$source] = @{
+ Cold = Get-Median $measurements[$source].ColdSeconds
+ Warm = Get-Median $measurements[$source].WarmSeconds
+ }
}
- $report | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath "$resultDirectory/comparison.json" -Encoding utf8
$summary = @(
"## Invoke-ScriptAnalyzer performance: $($baseline.Workload) / $($baseline.RunnerOS)"
''
'Release builds run in separate jobs with the same OS and PowerShell version and identical input bytes. No analyzer modules or sessions are shared between jobs.'
'The PowerShell workload analyzes only build.psm1 with default rules. The semver workload analyzes the repository recursively using its PSScriptAnalyzerSettings.psd1, without filtering findings.'
- 'Each build gets a fresh pwsh -NoProfile process: cold is its first analysis, warm is the immediately following analysis in that process.'
+ "Each build gets $env:BENCHMARK_REPETITIONS fresh pwsh -NoProfile processes: cold is the first analysis, warm is the immediately following analysis in each process."
'Shell startup, module import, checkout and build time are excluded. Cold does not mean an empty OS filesystem cache.'
''
- "| Source | Commit | Cold (s) | Warm (s) | Diagnostics (cold / warm) |"
- "| --- | --- | ---: | ---: | ---: |"
- "| PowerShell/PSScriptAnalyzer main | $($baseline.Revision) | $($baseline.ColdSeconds.ToString('F3')) | $($baseline.WarmSeconds.ToString('F3')) | $($baseline.ColdDiagnosticCount) / $($baseline.WarmDiagnosticCount) |"
- "| jessehouwing/PSScriptAnalyzer main | $($candidate.Revision) | $($candidate.ColdSeconds.ToString('F3')) | $($candidate.WarmSeconds.ToString('F3')) | $($candidate.ColdDiagnosticCount) / $($candidate.WarmDiagnosticCount) |"
- ''
+ "| Source | Commit | Cold median (s) | Warm median (s) | Cold range (s) | Warm range (s) |"
+ "| --- | --- | ---: | ---: | --- | --- |"
)
+ foreach ($source in 'upstream', 'fork') {
+ $samples = $measurements[$source]
+ $reference = 'main'
+ $cold = $samples.ColdSeconds | Measure-Object -Minimum -Maximum
+ $warm = $samples.WarmSeconds | Measure-Object -Minimum -Maximum
+ $marker = if (@($samples | Where-Object Inconsistent).Count) { ' ⚠️' } else { '' }
+ $summary += "| $($samples[0].Repository)@$reference$marker | $($samples[0].Revision) | $($medians[$source].Cold.ToString('F3')) | $($medians[$source].Warm.ToString('F3')) | $($cold.Minimum.ToString('F3'))–$($cold.Maximum.ToString('F3')) | $($warm.Minimum.ToString('F3'))–$($warm.Maximum.ToString('F3')) |"
+ }
+ $summary += ''
+ if ($report.UpstreamInconsistent) {
+ $failedAttempts = ($measurements.upstream | ForEach-Object { $_.Attempts - 1 } | Measure-Object -Sum).Sum
+ $summary += "⚠️ Upstream results were inconsistent: $failedAttempts failed attempts were retried. Timings include successful attempts only and may be biased; failed-attempt logs are retained in the raw artifacts."
+ $summary += ''
+ }
foreach ($run in 'Cold', 'Warm') {
- $property = "${run}Seconds"
- if ($baseline.$property -gt 0) {
- $change = 100 * ($candidate.$property / $baseline.$property - 1)
- $summary += "$run time change (fork vs upstream): $($change.ToString('F2'))% (positive is slower)."
+ foreach ($pair in @(, @('fork', 'upstream'))) {
+ if ($medians[$pair[1]][$run] -gt 0) {
+ $change = 100 * ($medians[$pair[0]][$run] / $medians[$pair[1]][$run] - 1)
+ $summary += "$run median time change ($($pair[0]) vs $($pair[1])): $($change.ToString('F2'))% (positive is slower)."
+ }
}
}
$summary += @(
@@ -256,25 +345,23 @@ jobs:
"Input SHA256: $($baseline.InputSHA256); PowerShell files: $($baseline.InputFileCount); recurse: $($baseline.Recurse)"
"Settings SHA256 (empty for default rules): $($baseline.SettingsSHA256)"
"PowerShell: $($baseline.PowerShellVersion)"
- "Upstream runner: $($baseline.RunnerImage) $($baseline.RunnerImageVersion); logical processors: $($baseline.ProcessorCount)"
- "Fork runner: $($candidate.RunnerImage) $($candidate.RunnerImageVersion); logical processors: $($candidate.ProcessorCount)"
''
- "Verified upstream module: $($baseline.ModuleManifest) (version $($baseline.ModuleVersion))"
- "Verified upstream cmdlet assembly: $($baseline.AnalyzerAssemblyPath)"
- "Verified fork module: $($candidate.ModuleManifest) (version $($candidate.ModuleVersion))"
- "Verified fork cmdlet assembly: $($candidate.AnalyzerAssemblyPath)"
- ''
- 'These are single cold/warm samples on separate hosts, not a statistical regression test. Rerun the workflow to assess runner and hardware noise.'
+ 'Samples run on separate hosts per build, not a controlled statistical regression test. Raw artifacts retain each measurement and full normalized diagnostics.'
)
+ foreach ($source in 'upstream', 'fork') {
+ $sample = $measurements[$source][0]
+ $summary += "$source runner: $($sample.RunnerImage) $($sample.RunnerImageVersion); logical processors: $($sample.ProcessorCount)"
+ $summary += "Verified $source module: $($sample.ModuleManifest); assembly: $($sample.AnalyzerAssemblyPath)"
+ }
if (-not $findingsMatch) {
- $summary += '**FAILED:** Expected a nonzero upstream cold finding count and the same count in all four runs. Timing results must not be treated as a valid comparison.'
+ $summary += '**FAILED:** Expected nonzero upstream findings and identical full diagnostic fingerprints across all samples and builds. Timing results must not be treated as a valid comparison.'
} else {
- $summary += "Finding count verified: all four runs returned $($baseline.ColdDiagnosticCount) findings, matching the upstream cold reference."
+ $summary += "Diagnostics verified: every sample returned the same $($baseline.ColdDiagnosticCount) findings, including locations, messages, suppression state, and corrections."
}
$summary | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
$summary | Write-Output
if (-not $findingsMatch) {
- throw 'Finding count validation failed. See the job summary and raw benchmark results.'
+ throw 'Diagnostic equivalence validation failed. See the job summary and raw benchmark results.'
}
- name: Upload comparison
@@ -284,3 +371,66 @@ jobs:
name: scriptanalyzer-performance-${{ matrix.os }}-${{ matrix.workload }}-comparison
path: results/comparison.json
if-no-files-found: warn
+
+ summary:
+ name: All performance comparisons
+ needs: compare
+ if: ${{ !cancelled() }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Download comparisons
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ pattern: scriptanalyzer-performance-*-comparison
+ path: comparisons
+
+ - name: Summarize all combinations
+ if: ${{ !cancelled() }}
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ function Get-Median($values) {
+ $sorted = @($values | Sort-Object)
+ $middle = [int][Math]::Floor($sorted.Count / 2)
+ if ($sorted.Count % 2) { return $sorted[$middle] }
+ return ($sorted[$middle - 1] + $sorted[$middle]) / 2
+ }
+ $summary = @(
+ '## Performance across all combinations'
+ ''
+ 'Times are medians of fresh-process cold/warm samples; lower is better.'
+ ''
+ '| OS | Workload | Source | Cold median (s) | Warm median (s) | Status |'
+ '| --- | --- | --- | ---: | ---: | --- |'
+ )
+ foreach ($os in 'ubuntu-latest', 'windows-latest') {
+ foreach ($workload in 'powershell', 'semver') {
+ $path = Join-Path $env:GITHUB_WORKSPACE "comparisons/scriptanalyzer-performance-$os-$workload-comparison/comparison.json"
+ $report = if (Test-Path -LiteralPath $path) {
+ Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
+ } else { $null }
+ foreach ($source in 'upstream', 'fork') {
+ $samples = @($report.Measurements | Where-Object Source -EQ $source)
+ if ($samples.Count -eq 0) {
+ $summary += "| $os | $workload | $source | — | — | Missing results |"
+ continue
+ }
+ $cold = (Get-Median $samples.ColdSeconds).ToString('F3', [Globalization.CultureInfo]::InvariantCulture)
+ $warm = (Get-Median $samples.WarmSeconds).ToString('F3', [Globalization.CultureInfo]::InvariantCulture)
+ $status = if ($report.FindingsVerified) { 'Verified' } else { 'INVALID: diagnostics differ' }
+ if (@($samples | Where-Object Inconsistent).Count) {
+ $status += '; ⚠️ retried samples'
+ }
+ $summary += "| $os | $workload | $source | $cold | $warm | $status |"
+ }
+ }
+ }
+ $summary += @(
+ ''
+ 'Sources: upstream = PowerShell/PSScriptAnalyzer@main; fork = jessehouwing/PSScriptAnalyzer@main.'
+ 'Compare timings only within the same OS/workload. Invalid results must not be used for performance comparisons; retried samples may be biased.'
+ 'Builds run on separate hosts, not a controlled statistical regression test. Per-combination summaries and artifacts retain revisions, ranges, diagnostics, and retry details.'
+ )
+ $summary | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY
+ $summary | Write-Output
diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs
index af76f928e..56d7585df 100644
--- a/Engine/CommandInfoCache.cs
+++ b/Engine/CommandInfoCache.cs
@@ -16,15 +16,13 @@ namespace Microsoft.Windows.PowerShell.ScriptAnalyzer
///
internal class CommandInfoCache : IDisposable
{
- ///
- /// Number of times a command lookup is attempted before giving up.
- /// Command lookups can fail transiently because the PowerShell engine is not thread safe,
- /// see https://github.com/PowerShell/PowerShell/issues/4003
- ///
- private const int MaxLookupAttempts = 3;
private const string GetCommandName = "Microsoft.PowerShell.Core\\Get-Command";
private readonly ConcurrentDictionary> _commandInfoCache;
+ private readonly ConcurrentDictionary> _parameterSnapshots
+ = new ConcurrentDictionary>();
+ private readonly ConcurrentDictionary> _mandatoryParameters
+ = new ConcurrentDictionary>();
///
/// Guards all access to so that only one thread at a time drives the
@@ -36,7 +34,7 @@ internal class CommandInfoCache : IDisposable
private readonly object _runspaceLock = new object();
private readonly Runspace _runspace;
- private bool disposed = false;
+ private volatile bool disposed = false;
///
/// Create a fresh command info cache instance.
@@ -86,6 +84,19 @@ protected virtual void Dispose(bool disposing)
/// When needed due to runspace affinity problems of some PowerShell objects.
///
public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes = null, bool bypassCache = false)
+ {
+ try
+ {
+ return GetCachedCommandInfo(commandName, commandTypes, bypassCache);
+ }
+ catch (Exception exception) when (IsGetCommandResolutionException(exception))
+ {
+ // Failed Lazy lookups have already been evicted; never cache a lookup failure as a miss.
+ return null;
+ }
+ }
+
+ private CommandInfo GetCachedCommandInfo(string commandName, CommandTypes? commandTypes, bool bypassCache)
{
if (string.IsNullOrWhiteSpace(commandName))
{
@@ -98,7 +109,10 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes
return GetCommandInfoInternal(commandName, commandTypes);
}
// Atomically either use PowerShell to query a command info object, or fetch it from the cache
- var lazyCommandInfo = _commandInfoCache.GetOrAdd(key, new Lazy(() => GetCommandInfoInternal(commandName, commandTypes)));
+ if (!_commandInfoCache.TryGetValue(key, out var lazyCommandInfo))
+ {
+ lazyCommandInfo = _commandInfoCache.GetOrAdd(key, CreateLookup(commandName, commandTypes));
+ }
try
{
return lazyCommandInfo.Value;
@@ -109,12 +123,21 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes
// command fail for the lifetime of the process. Evict the entry so that the next lookup
// can try again. Only remove the faulted instance so that a replacement that another
// thread may already have added is left alone.
- ((ICollection>>)_commandInfoCache)
- .Remove(new KeyValuePair>(key, lazyCommandInfo));
+ RemoveLookup(key, lazyCommandInfo);
throw;
}
}
+ private void RemoveLookup(CommandLookupKey key, Lazy lookup)
+ {
+ ((ICollection>>)_commandInfoCache)
+ .Remove(new KeyValuePair>(key, lookup));
+ }
+
+ private Lazy CreateLookup(string commandName, CommandTypes? commandTypes)
+ {
+ return new Lazy(() => GetCommandInfoInternal(commandName, commandTypes));
+ }
///
/// Get a CommandInfo object of the given command name
@@ -141,66 +164,48 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command
// For more details see https://github.com/PowerShell/PowerShell/issues/9308
actualCmdName = WildcardPattern.Escape(actualCmdName);
- for (int attempt = 1; ; attempt++)
+ // Serialize all use of the PowerShell engine. Only cache misses reach this point;
+ // lookups that are already cached are served without taking the lock.
+ lock (_runspaceLock)
{
- // Serialize all use of the PowerShell engine. Only cache misses reach this point;
- // lookups that are already cached are served without taking the lock.
- lock (_runspaceLock)
+ if (disposed)
{
- if (disposed)
+ return null;
+ }
+
+ using (var ps = System.Management.Automation.PowerShell.Create())
+ {
+ ps.Runspace = _runspace;
+
+ ps.AddCommand(GetCommandName)
+ .AddParameter("Name", actualCmdName)
+ .AddParameter("ErrorAction", "SilentlyContinue");
+
+ if (commandType != null)
{
- return null;
+ ps.AddParameter("CommandType", commandType);
}
- using (var ps = System.Management.Automation.PowerShell.Create())
+ if (!string.IsNullOrEmpty(moduleName))
{
- ps.Runspace = _runspace;
-
- ps.AddCommand(GetCommandName)
- .AddParameter("Name", actualCmdName)
- .AddParameter("ErrorAction", "SilentlyContinue");
-
- if (commandType != null)
- {
- ps.AddParameter("CommandType", commandType);
- }
-
- if (!string.IsNullOrEmpty(moduleName))
- {
- ps.AddParameter("Module", moduleName);
- }
-
- try
- {
- var result = ps.Invoke();
- if (ps.HadErrors && ps.Streams.Error.All(IsGetCommandResolutionError))
- {
- if (attempt >= MaxLookupAttempts)
- {
- return null;
- }
-
- continue;
- }
-
- return result.FirstOrDefault();
- }
- // 'Get-Command' is invoked with 'SilentlyContinue', so a CommandNotFoundException can only
- // mean that the engine failed to resolve 'Get-Command' itself in the runspace.
- // That happened intermittently when lookups ran concurrently because the PowerShell engine
- // is not thread safe, see https://github.com/PowerShell/PowerShell/issues/4003 and
- // https://github.com/PowerShell/PSScriptAnalyzer/issues/2205
- // Lookups are serialized now, so this should no longer occur, but the retry is kept as a
- // safety net for hosts that drive the engine from other threads at the same time.
- catch (RuntimeException exception) when (IsGetCommandResolutionException(exception))
- {
- if (attempt >= MaxLookupAttempts)
- {
- return null;
- }
- }
+ ps.AddParameter("Module", moduleName);
+ }
+ Collection result = ps.Invoke();
+
+ // 'Get-Command' is invoked with 'SilentlyContinue', so a resolution error can only
+ // mean that the engine failed to resolve 'Get-Command' itself in the runspace.
+ // That happened intermittently when lookups ran concurrently because the PowerShell engine
+ // is not thread safe, see https://github.com/PowerShell/PowerShell/issues/4003 and
+ // https://github.com/PowerShell/PSScriptAnalyzer/issues/2205
+ // Lookups are serialized now, so this should no longer occur; if it does, the cache
+ // entry is evicted and the lookup surfaces as a null command info.
+ // SilentlyContinue can set HadErrors without populating the stream for an unknown name.
+ if (ps.HadErrors && ps.Streams.Error.Count > 0 && ps.Streams.Error.All(IsGetCommandResolutionError))
+ {
+ throw ps.Streams.Error[0].Exception;
}
+ return result.FirstOrDefault();
}
}
}
@@ -216,15 +221,16 @@ private static bool IsGetCommandResolutionError(ErrorRecord errorRecord)
public Dictionary GetCommandParameters(
string commandName, CommandTypes? commandTypes = null, bool bypassCache = false)
{
- // Resolve the Lazy value before taking the lock: its factory may already be
- // running on another thread that needs the same lock to finish the lookup.
- var commandInfo = GetCommandInfo(commandName, commandTypes, bypassCache);
- lock (_runspaceLock)
+ return GetCommandMetadata(commandName, commandTypes, bypassCache, command =>
{
- // Dynamic parameter getters execute PowerShell code and mutate runspace state,
- // even though they look like ordinary property reads.
- return disposed ? null : commandInfo?.Parameters;
- }
+ lock (_runspaceLock)
+ {
+ // Dynamic parameter getters execute PowerShell code and mutate runspace state,
+ // even though they look like ordinary property reads.
+ if (disposed) return null;
+ return command.Parameters;
+ }
+ });
}
///
@@ -232,10 +238,124 @@ public Dictionary GetCommandParameters(
///
public ReadOnlyCollection GetCommandParameterSets(string commandName)
{
- var commandInfo = GetCommandInfo(commandName);
+ return GetCommandMetadata(commandName, null, false, command =>
+ {
+ lock (_runspaceLock)
+ {
+ if (disposed) return null;
+ return command.ParameterSets;
+ }
+ });
+ }
+
+ ///
+ /// Contains PowerShell metadata failures: the failed command object is evicted so subsequent
+ /// calls can recover, and null is returned. Unavailable metadata is never cached.
+ /// Unexpected exceptions still propagate.
+ ///
+ private T GetCommandMetadata(
+ string commandName, CommandTypes? commandTypes, bool bypassCache, Func readMetadata)
+ where T : class
+ {
+ // Resolve Lazy values outside the runspace lock: another thread's factory may need it.
+ var command = GetCommandInfo(commandName, commandTypes, bypassCache);
+ if (disposed || command == null || command.CommandType == CommandTypes.Application) return null;
+ try
+ {
+ return readMetadata(command);
+ }
+ catch (Exception exception) when (IsMetadataException(exception))
+ {
+ var key = new CommandLookupKey(commandName, commandTypes);
+ if (_commandInfoCache.TryGetValue(key, out var lookup)
+ && lookup.IsValueCreated && ReferenceEquals(lookup.Value, command))
+ {
+ RemoveLookup(key, lookup);
+ }
+ return null;
+ }
+ }
+
+ private static bool IsRunspaceAffinityException(Exception exception)
+ {
+ // PowerShell objects can have runspace affinity, see PowerShell issue 4003 and PSSA issue 1708.
+ return exception is InvalidOperationException || exception is NullReferenceException;
+ }
+
+ private static bool IsMetadataException(Exception exception)
+ {
+ return IsRunspaceAffinityException(exception)
+ || exception is RuntimeException || exception is PSNotSupportedException;
+ }
+
+ private static CmdletInfo GetStaticCmdlet(CommandInfo command)
+ {
+ // Aliases, functions, subclasses and provider/dynamic cmdlets retain the locked,
+ // uncached path. IDynamicParameters includes implementations inherited from base types.
+ if (command == null || command.GetType() != typeof(CmdletInfo)) return null;
+ var cmdlet = (CmdletInfo)command;
+ return cmdlet.ImplementingType != null
+ && !typeof(IDynamicParameters).IsAssignableFrom(cmdlet.ImplementingType) ? cmdlet : null;
+ }
+
+ public IReadOnlyDictionary GetParameterSnapshot(
+ string commandName, CommandTypes? commandTypes = null, bool bypassCache = false)
+ {
+ return GetCommandMetadata(commandName, commandTypes, bypassCache,
+ command => GetParameterSnapshot(command, bypassCache));
+ }
+
+ private IReadOnlyDictionary GetParameterSnapshot(CommandInfo command, bool bypassCache)
+ {
+ var staticCmdlet = bypassCache ? null : GetStaticCmdlet(command);
+ if (disposed || command == null) return null;
+ if (staticCmdlet != null && _parameterSnapshots.TryGetValue(staticCmdlet, out var cached)) return cached;
+
+ lock (_runspaceLock)
+ {
+ if (disposed) return null;
+ if (staticCmdlet != null && _parameterSnapshots.TryGetValue(staticCmdlet, out cached)) return cached;
+ var parameters = command.Parameters;
+ if (parameters == null) return null;
+ var snapshot = new ReadOnlyDictionary(
+ parameters.ToDictionary(p => p.Key, p => new CommandParameterSnapshot(p.Value), parameters.Comparer));
+ if (staticCmdlet != null) _parameterSnapshots[staticCmdlet] = snapshot;
+ return snapshot;
+ }
+ }
+
+ public IReadOnlyList GetMandatoryParameterNames(string commandName)
+ {
+ return GetCommandMetadata(commandName, null, false,
+ command => GetMandatoryParameterNames(command, bypassCache: false));
+ }
+
+ private IReadOnlyList GetMandatoryParameterNames(CommandInfo command, bool bypassCache)
+ {
+ var staticCmdlet = bypassCache ? null : GetStaticCmdlet(command);
+ if (disposed || command == null) return null;
+ if (staticCmdlet != null && _mandatoryParameters.TryGetValue(staticCmdlet, out var cached)) return cached;
+
lock (_runspaceLock)
{
- return disposed ? null : commandInfo?.ParameterSets;
+ if (disposed) return null;
+ if (staticCmdlet != null && _mandatoryParameters.TryGetValue(staticCmdlet, out cached)) return cached;
+ var parameterSets = command.ParameterSets;
+ var parameters = command.Parameters;
+ if (parameterSets == null || parameters == null) return null;
+ int setCount = parameterSets.Count;
+ var mandatory = new List();
+ foreach (var parameter in parameters.Values)
+ {
+ if (parameter.Attributes.Count >= setCount
+ && parameter.Attributes.OfType().Count(a => a.Mandatory) >= setCount)
+ {
+ mandatory.Add(parameter.Name);
+ }
+ }
+ var snapshot = mandatory.AsReadOnly();
+ if (staticCmdlet != null) _mandatoryParameters[staticCmdlet] = snapshot;
+ return snapshot;
}
}
@@ -278,7 +398,7 @@ public override int GetHashCode()
unchecked
{
int hash = 17;
- hash = hash * 31 + Name.ToUpperInvariant().GetHashCode();
+ hash = hash * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
hash = hash * 31 + CommandTypes.GetHashCode();
return hash;
}
diff --git a/Engine/CommandParameterSnapshot.cs b/Engine/CommandParameterSnapshot.cs
new file mode 100644
index 000000000..75ccc3650
--- /dev/null
+++ b/Engine/CommandParameterSnapshot.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Management.Automation;
+
+namespace Microsoft.Windows.PowerShell.ScriptAnalyzer
+{
+ /// Detached parameter facts, safe to consume without driving a PowerShell runspace.
+ public sealed class CommandParameterSnapshot
+ {
+ public string Name { get; }
+ public bool SwitchParameter { get; }
+ public ReadOnlyCollection Aliases { get; }
+
+ internal CommandParameterSnapshot(ParameterMetadata parameter)
+ {
+ Name = parameter.Name;
+ SwitchParameter = parameter.SwitchParameter;
+ Aliases = new ReadOnlyCollection(parameter.Aliases.ToArray());
+ }
+ }
+}
diff --git a/Engine/Helper.cs b/Engine/Helper.cs
index 9bdc6a0c8..a9633156f 100644
--- a/Engine/Helper.cs
+++ b/Engine/Helper.cs
@@ -330,6 +330,15 @@ public PSModuleInfo GetModuleManifest(string filePath, out IEnumerableShares validation between built-in rules during one syntax-tree analysis only.
+ public PSModuleInfo GetModuleManifestForAnalysis(string filePath, out IEnumerable errorRecords)
+ {
+ var cache = ModuleManifestAnalysisCache.Current;
+ return cache == null
+ ? GetModuleManifest(filePath, out errorRecords)
+ : cache.Get(this, filePath, out errorRecords);
+ }
+
///
/// Checks if the error record is MissingMemberException
///
@@ -399,18 +408,22 @@ public HashSet GetExportedFunction(Ast ast)
List exportFunctionsCmdlet = Helper.Instance.CmdletNameAndAliases("export-modulemember");
// find functions exported
- IEnumerable cmdAsts = ast.FindAll(item => item is CommandAst
- && exportFunctionsCmdlet.Contains((item as CommandAst).GetCommandName(), StringComparer.OrdinalIgnoreCase), true);
+ var cmdAsts = ast.FindAll(item => item is CommandAst
+ && exportFunctionsCmdlet.Contains((item as CommandAst).GetCommandName(), StringComparer.OrdinalIgnoreCase), true).ToArray();
+ if (cmdAsts.Length == 0)
+ {
+ return exportedFunctions;
+ }
// Export-ModuleMember has no dynamic parameters. Resolve names from its static
// metadata instead of ResolveParameter(), which re-enters the cached command's
// runspace and races with command lookups and metadata queries on other rule threads.
- var parameters = GetCommandParameters("export-modulemember", CommandTypes.Cmdlet);
+ var parameters = GetCommandParameterSnapshot("export-modulemember", CommandTypes.Cmdlet);
if (parameters == null)
{
return exportedFunctions;
}
- IEnumerable switchParams = parameters.Values.Where(pm => pm.SwitchParameter);
+ var switchParams = parameters.Values.Where(pm => pm.SwitchParameter);
foreach (CommandAst cmdAst in cmdAsts)
{
@@ -429,7 +442,7 @@ public HashSet GetExportedFunction(Ast ast)
if (ceAst is CommandParameterAst)
{
var paramAst = ceAst as CommandParameterAst;
- ParameterMetadata param;
+ CommandParameterSnapshot param;
if (!parameters.TryGetValue(paramAst.ParameterName, out param))
{
param = parameters.Values.FirstOrDefault(pm =>
@@ -685,7 +698,7 @@ public CommandInfo GetCommandInfo(string name, CommandTypes? commandType = null,
}
///
- /// Retrieves command parameters while serializing access to the cached command's runspace.
+ /// Retrieves command parameters with centralized recovery; returns null when metadata is unavailable.
///
public Dictionary GetCommandParameters(
string name, CommandTypes? commandType = null, bool bypassCache = false)
@@ -694,13 +707,26 @@ public Dictionary GetCommandParameters(
}
///
- /// Retrieves command parameter sets while serializing access to the cached command's runspace.
+ /// Retrieves command parameter sets with centralized recovery; returns null when metadata is unavailable.
///
public ReadOnlyCollection GetCommandParameterSets(string name)
{
return CommandInfoCache.GetCommandParameterSets(name);
}
+ /// Gets detached parameter facts or null when unavailable; only static cmdlet metadata is cached.
+ public IReadOnlyDictionary GetCommandParameterSnapshot(
+ string name, CommandTypes? commandType = null, bool bypassCache = false)
+ {
+ return CommandInfoCache.GetParameterSnapshot(name, commandType, bypassCache);
+ }
+
+ /// Gets the mandatory parameter summary under a single runspace lock, or null when unavailable.
+ public IReadOnlyList GetMandatoryParameterNames(string name)
+ {
+ return CommandInfoCache.GetMandatoryParameterNames(name);
+ }
+
///
/// Returns the get, set and test targetresource dsc function
///
diff --git a/Engine/ModuleManifestAnalysisCache.cs b/Engine/ModuleManifestAnalysisCache.cs
new file mode 100644
index 000000000..4564f88e7
--- /dev/null
+++ b/Engine/ModuleManifestAnalysisCache.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Management.Automation;
+
+namespace Microsoft.Windows.PowerShell.ScriptAnalyzer
+{
+ // Owned by one AnalyzeSyntaxTree call and explicitly installed on its rule workers.
+ // It cannot outlive that analysis or affect unrelated public GetModuleManifest callers.
+ internal sealed class ModuleManifestAnalysisCache
+ {
+ [ThreadStatic]
+ internal static ModuleManifestAnalysisCache Current;
+
+ private readonly Dictionary results = new Dictionary(StringComparer.Ordinal);
+
+ internal PSModuleInfo Get(Helper helper, string path, out IEnumerable errors)
+ {
+ lock (results)
+ {
+ if (!results.TryGetValue(path, out var result))
+ {
+ var module = helper.GetModuleManifest(path, out var moduleErrors);
+ result = new Result { Module = module, Errors = moduleErrors };
+ results.Add(path, result);
+ }
+ errors = result.Errors;
+ return result.Module;
+ }
+ }
+
+ internal Scope Enter() => new Scope(this);
+
+ internal struct Scope : IDisposable
+ {
+ private readonly ModuleManifestAnalysisCache previous;
+
+ internal Scope(ModuleManifestAnalysisCache cache)
+ {
+ previous = Current;
+ Current = cache;
+ }
+
+ public void Dispose() => Current = previous;
+ }
+
+ private sealed class Result
+ {
+ internal PSModuleInfo Module;
+ internal IEnumerable Errors;
+ }
+ }
+}
diff --git a/Engine/ScriptAnalyzer.cs b/Engine/ScriptAnalyzer.cs
index adc81f2d3..9fed504ec 100644
--- a/Engine/ScriptAnalyzer.cs
+++ b/Engine/ScriptAnalyzer.cs
@@ -2128,6 +2128,7 @@ public IEnumerable AnalyzeSyntaxTree(
string fileName = filePathIsNullOrWhiteSpace ? String.Empty : System.IO.Path.GetFileName(filePath);
if (this.ScriptRules != null)
{
+ var manifestCache = new ModuleManifestAnalysisCache();
var allowedRules = this.ScriptRules.Where(IsRuleAllowed);
if (allowedRules.Any())
{
@@ -2139,6 +2140,7 @@ public IEnumerable AnalyzeSyntaxTree(
// Ensure that any unhandled errors from Rules are converted to non-terminating errors
// We want the Engine to continue functioning even if one or more Rules throws an exception
+ using (manifestCache.Enter())
try
{
if (helpRule && helpFile)
diff --git a/README.md b/README.md
index 0d3405bf2..c00eb8dce 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,12 @@ To install **PSScriptAnalyzer** from source code:
Import-Module .\out\PSScriptAnalyzer\[version]\PSScriptAnalyzer.psd1
```
+- Command metadata handling lives in `CommandInfoCache`, not individual rules. Its parameter,
+ parameter-set and snapshot APIs return null when PowerShell metadata is unavailable, while
+ unexpected exceptions still propagate. Failed lookups and command objects with failed
+ metadata are evicted so subsequent calls can recover; genuine missing commands retain
+ negative caching.
+
To confirm installation: run `Get-ScriptAnalyzerRule` in the PowerShell console to obtain the
built-in rules.
diff --git a/Rules/MissingModuleManifestField.cs b/Rules/MissingModuleManifestField.cs
index 180767766..8b7b40f7e 100644
--- a/Rules/MissingModuleManifestField.cs
+++ b/Rules/MissingModuleManifestField.cs
@@ -40,7 +40,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
if (Helper.IsModuleManifest(fileName))
{
IEnumerable errorRecords;
- var psModuleInfo = Helper.Instance.GetModuleManifest(fileName, out errorRecords);
+ var psModuleInfo = Helper.Instance.GetModuleManifestForAnalysis(fileName, out errorRecords);
if (errorRecords != null)
{
foreach (var errorRecord in errorRecords)
diff --git a/Rules/UseCmdletCorrectly.cs b/Rules/UseCmdletCorrectly.cs
index 89e895ea0..fda126a21 100644
--- a/Rules/UseCmdletCorrectly.cs
+++ b/Rules/UseCmdletCorrectly.cs
@@ -145,41 +145,9 @@ private bool MandatoryParameterExists(CommandAst cmdAst)
// Gets mandatory parameters from cmdlet.
// If cannot find any mandatory parameter, it's not necessary to do a further check for current cmdlet.
- var mandatoryParameters = new List();
- try
- {
- int noOfParamSets = Helper.Instance.GetCommandParameterSets(cmdAst.GetCommandName()).Count;
- foreach (ParameterMetadata pm in Helper.Instance.GetCommandParameters(cmdAst.GetCommandName()).Values)
- {
- int count = 0;
-
- if (pm.Attributes.Count < noOfParamSets)
- {
- continue;
- }
-
- foreach (Attribute attr in pm.Attributes)
- {
- if (!(attr is ParameterAttribute)) continue;
- if (((ParameterAttribute)attr).Mandatory)
- {
- count += 1;
- }
- }
+ var mandatoryParameters = Helper.Instance.GetMandatoryParameterNames(cmdAst.GetCommandName());
- if (count >= noOfParamSets)
- {
- mandatoryParameters.Add(pm);
- }
- }
- }
- catch (Exception)
- {
- // For cases like cmd.exe. Also for runtime exception
- return true;
- }
-
- if (mandatoryParameters.Count == 0)
+ if (mandatoryParameters == null || mandatoryParameters.Count == 0)
{
return true;
}
@@ -188,8 +156,8 @@ private bool MandatoryParameterExists(CommandAst cmdAst)
foreach (CommandElementAst commandElementAst in cmdAst.CommandElements.OfType())
{
CommandParameterAst cpAst = (CommandParameterAst)commandElementAst;
- if (mandatoryParameters.Count(item =>
- item.Name.Equals(cpAst.ParameterName, StringComparison.OrdinalIgnoreCase)) > 0)
+ if (mandatoryParameters.Any(item =>
+ item.Equals(cpAst.ParameterName, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
@@ -254,5 +222,3 @@ public string GetSourceName()
}
}
-
-
diff --git a/Rules/UseCorrectCasing.cs b/Rules/UseCorrectCasing.cs
index a2ef3ad91..8a5ce9e12 100644
--- a/Rules/UseCorrectCasing.cs
+++ b/Rules/UseCorrectCasing.cs
@@ -119,21 +119,12 @@ public override IEnumerable AnalyzeScript(Ast ast, string file
}
var commandParameterAsts = commandAst.FindAll(
- testAst => testAst is CommandParameterAst, true).Cast();
- Dictionary availableParameters;
- try
+ testAst => testAst is CommandParameterAst, true).Cast().ToArray();
+ if (commandParameterAsts.Length == 0)
{
- availableParameters = Helper.Instance.GetCommandParameters(commandName);
- }
- // It's a known issue that objects from PowerShell can have a runspace affinity,
- // therefore if that happens, we query a fresh object instead of using the cache.
- // https://github.com/PowerShell/PowerShell/issues/4003
- // The affinity problem surfaces as an InvalidOperationException or as a
- // NullReferenceException, see https://github.com/PowerShell/PSScriptAnalyzer/issues/1708
- catch (Exception exception) when (exception is InvalidOperationException || exception is NullReferenceException)
- {
- availableParameters = GetParametersFromFreshCommandInfo(commandName);
+ continue;
}
+ var availableParameters = Helper.Instance.GetCommandParameterSnapshot(commandName);
if (availableParameters is null)
{
// The parameters of this command cannot be determined reliably,
@@ -143,7 +134,7 @@ public override IEnumerable AnalyzeScript(Ast ast, string file
foreach (var commandParameterAst in commandParameterAsts)
{
var parameterName = commandParameterAst.ParameterName;
- if (availableParameters.TryGetValue(parameterName, out ParameterMetadata parameterMetaData))
+ if (availableParameters.TryGetValue(parameterName, out CommandParameterSnapshot parameterMetaData))
{
var correctlyCasedParameterName = parameterMetaData.Name;
if (!parameterName.Equals(correctlyCasedParameterName, StringComparison.Ordinal))
@@ -168,22 +159,6 @@ public override IEnumerable AnalyzeScript(Ast ast, string file
}
}
- ///
- /// Queries a fresh object to work around the runspace affinity problem
- /// of the PowerShell engine and returns its parameters, or null if they cannot be determined.
- ///
- private Dictionary GetParametersFromFreshCommandInfo(string commandName)
- {
- try
- {
- return Helper.Instance.GetCommandParameters(commandName, bypassCache: true);
- }
- catch (Exception exception) when (exception is InvalidOperationException || exception is NullReferenceException)
- {
- return null;
- }
- }
-
///
/// For a command like "gci -path c:", returns the extent of "gci" in the command
///
diff --git a/Rules/UseToExportFieldsInManifest.cs b/Rules/UseToExportFieldsInManifest.cs
index 9bf612f83..2dcaabe31 100644
--- a/Rules/UseToExportFieldsInManifest.cs
+++ b/Rules/UseToExportFieldsInManifest.cs
@@ -51,7 +51,7 @@ public IEnumerable AnalyzeScript(Ast ast, string fileName)
// check if valid module manifest
IEnumerable errorRecord = null;
- PSModuleInfo psModuleInfo = Helper.Instance.GetModuleManifest(fileName, out errorRecord);
+ PSModuleInfo psModuleInfo = Helper.Instance.GetModuleManifestForAnalysis(fileName, out errorRecord);
if ((errorRecord != null && errorRecord.Count() > 0) || psModuleInfo == null)
{
yield break;
diff --git a/Tests/Build/MeasureScriptAnalyzerPerformance.tests.ps1 b/Tests/Build/MeasureScriptAnalyzerPerformance.tests.ps1
new file mode 100644
index 000000000..03f512895
--- /dev/null
+++ b/Tests/Build/MeasureScriptAnalyzerPerformance.tests.ps1
@@ -0,0 +1,229 @@
+# Copyright (c) Microsoft Corporation. All rights reserved.
+# Licensed under the MIT License.
+
+Describe 'Performance benchmark diagnostic normalization' -Skip:($PSVersionTable.PSVersion.Major -lt 7) {
+ BeforeAll {
+ $harnessPath = Join-Path $PSScriptRoot '../../tools/Measure-ScriptAnalyzerPerformance.ps1'
+ $tokens = $null
+ $parseErrors = $null
+ $harness = [System.Management.Automation.Language.Parser]::ParseFile(
+ $harnessPath, [ref]$tokens, [ref]$parseErrors)
+ if ($parseErrors.Count) { throw $parseErrors[0] }
+ foreach ($name in 'ConvertTo-WorkloadPath', 'ConvertTo-DiagnosticJson') {
+ $function = $harness.Find({
+ param($node)
+ $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name
+ }, $false)
+ . ([scriptblock]::Create($function.Extent.Text))
+ }
+
+ function Get-TestDiagnostic {
+ param([string]$Root)
+ $file = Join-Path $Root 'example.ps1'
+ [pscustomobject]@{
+ RuleName = 'PSUseCorrectCasing'
+ Severity = 'Information'
+ Message = "Incorrect casing in $Root"
+ ScriptName = 'example.ps1'
+ ScriptPath = $file
+ RuleSuppressionID = 'Get-Item'
+ IsSuppressed = $false
+ Extent = [pscustomobject]@{
+ File = $file
+ StartLineNumber = 1
+ StartColumnNumber = 1
+ EndLineNumber = 1
+ EndColumnNumber = 9
+ StartOffset = 0
+ EndOffset = 8
+ Text = 'get-item'
+ }
+ SuggestedCorrections = @([pscustomobject]@{
+ File = $file
+ StartLineNumber = 1
+ StartColumnNumber = 1
+ EndLineNumber = 1
+ EndColumnNumber = 9
+ Text = 'Get-Item'
+ Description = 'Correct command casing'
+ })
+ }
+ }
+ }
+
+ It 'ignores checkout-root differences while preserving diagnostic content' {
+ $inputRoot = Join-Path $TestDrive 'first'
+ $first = ConvertTo-DiagnosticJson (Get-TestDiagnostic $inputRoot)
+ $inputRoot = Join-Path $TestDrive 'second'
+ $second = ConvertTo-DiagnosticJson (Get-TestDiagnostic $inputRoot)
+ $first | Should -BeExactly $second
+ ($first | ConvertFrom-Json).ScriptPath | Should -BeExactly 'example.ps1'
+ }
+
+ It 'detects changes in even when diagnostic counts match' -ForEach @(
+ @{ Field = 'Message' }
+ @{ Field = 'RuleName' }
+ @{ Field = 'RuleSuppressionID' }
+ ) {
+ $inputRoot = $TestDrive
+ $diagnostic = Get-TestDiagnostic $inputRoot
+ $before = ConvertTo-DiagnosticJson $diagnostic
+ $diagnostic.$Field = 'changed'
+ ConvertTo-DiagnosticJson $diagnostic | Should -Not -BeExactly $before
+ }
+
+ It 'preserves locations, suppression state and suggested corrections' {
+ $inputRoot = $TestDrive
+ $diagnostic = Get-TestDiagnostic $inputRoot
+ $before = ConvertTo-DiagnosticJson $diagnostic
+ $diagnostic.Extent.StartOffset = 1
+ ConvertTo-DiagnosticJson $diagnostic | Should -Not -BeExactly $before
+ $diagnostic.Extent.StartOffset = 0
+ $diagnostic.IsSuppressed = $true
+ ConvertTo-DiagnosticJson $diagnostic | Should -Not -BeExactly $before
+ $diagnostic.IsSuppressed = $false
+ $diagnostic.SuggestedCorrections[0].Text = 'Different-Command'
+ ConvertTo-DiagnosticJson $diagnostic | Should -Not -BeExactly $before
+ }
+
+ It 'handles missing extents and corrections' {
+ $inputRoot = $TestDrive
+ $diagnostic = Get-TestDiagnostic $inputRoot
+ $diagnostic.Extent = $null
+ $diagnostic.SuggestedCorrections = $null
+ $result = ConvertTo-DiagnosticJson $diagnostic | ConvertFrom-Json
+ $result.Extent | Should -BeNullOrEmpty
+ $result.SuggestedCorrections.Count | Should -Be 0
+ }
+}
+
+Describe 'Performance comparison validation' -Skip:($PSVersionTable.PSVersion.Major -lt 7) {
+ BeforeAll {
+ $workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../.github/workflows/performance.yml') -Raw
+ $comparison = [regex]::Match($workflow, '(?ms) - name: Validate and summarize\r?\n.*? run: \|\r?\n(?