From b3d23b2a5edc615d90ef481b49160dc94c47ac4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:01:28 +0000 Subject: [PATCH 1/2] Record three-way benchmark requirement Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- .github/workflows/performance.yml | 71 +++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 127a70f19..181691697 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -2,6 +2,16 @@ 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' + collect_metrics: + description: Collect optional lock/caching metrics (adds measurement overhead) + type: boolean + default: false permissions: contents: read @@ -14,7 +24,27 @@ 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 }} + perf: ${{ steps.analyzer-revisions.outputs.perf }} 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' }, + @{ Name = 'perf'; Repository = 'jessehouwing/PSScriptAnalyzer'; Ref = 'perf' } + )) { + $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: @@ -75,12 +105,14 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] workload: [powershell, semver] - source: [upstream, fork] + source: [upstream, fork, perf] include: - source: upstream repository: PowerShell/PSScriptAnalyzer - source: fork repository: jessehouwing/PSScriptAnalyzer + - source: perf + repository: jessehouwing/PSScriptAnalyzer defaults: run: shell: pwsh @@ -91,6 +123,9 @@ 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 }} + BENCHMARK_METRICS: ${{ inputs.collect_metrics }} steps: - name: Checkout benchmark harness uses: actions/checkout@v4 @@ -98,11 +133,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 +162,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 +183,13 @@ 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)." + if ($env:BENCHMARK_METRICS -eq 'true') { + $analysisArguments += '-CollectMetrics' } - $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,9 +202,19 @@ 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" + & $pwsh -NoLogo -NoProfile -NonInteractive -File $measureScript ` + -ModulePath $manifests[0].FullName -ScriptPath $scriptPath -ResultPath $resultPath @analysisArguments + if ($LASTEXITCODE -ne 0) { + throw "Benchmark sample $sample failed (exit code $LASTEXITCODE)." + } + $result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json + $result | Add-Member -NotePropertyMembers $metadata + $result | Add-Member -NotePropertyName Sample -NotePropertyValue $sample + $result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $resultPath -Encoding utf8 + $result | Format-List + } - name: Upload raw benchmark results if: always() From 37e07fdf0b75e2cbab184f57083d5274c7efb740 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:13:18 +0000 Subject: [PATCH 2/2] Optimize synchronized metadata and add three-way performance benchmarks Co-authored-by: jessehouwing <4173387+jessehouwing@users.noreply.github.com> --- .github/workflows/performance.yml | 143 +++++++++----- Engine/CommandInfoCache.cs | 98 +++++++++- Engine/CommandParameterSnapshot.cs | 24 +++ Engine/Helper.cs | 39 +++- Engine/ModuleManifestAnalysisCache.cs | 55 ++++++ Engine/PerformanceTelemetry.cs | 82 ++++++++ Engine/ScriptAnalyzer.cs | 2 + Rules/MissingModuleManifestField.cs | 2 +- Rules/UseCmdletCorrectly.cs | 34 +--- Rules/UseCorrectCasing.cs | 16 +- Rules/UseToExportFieldsInManifest.cs | 2 +- ...MeasureScriptAnalyzerPerformance.tests.ps1 | 98 ++++++++++ .../AnalysisMetadataPerformance.tests.ps1 | 96 ++++++++++ Tests/Engine/MetadataSnapshots.tests.ps1 | 176 ++++++++++++++++++ tools/Measure-ScriptAnalyzerPerformance.ps1 | 93 ++++++++- 15 files changed, 857 insertions(+), 103 deletions(-) create mode 100644 Engine/CommandParameterSnapshot.cs create mode 100644 Engine/ModuleManifestAnalysisCache.cs create mode 100644 Engine/PerformanceTelemetry.cs create mode 100644 Tests/Build/MeasureScriptAnalyzerPerformance.tests.ps1 create mode 100644 Tests/Engine/AnalysisMetadataPerformance.tests.ps1 create mode 100644 Tests/Engine/MetadataSnapshots.tests.ps1 diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 181691697..492f969d9 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -204,14 +204,29 @@ jobs: } for ($sample = 1; $sample -le [int]$env:BENCHMARK_REPETITIONS; $sample++) { $resultPath = Join-Path $resultDirectory "$env:BENCHMARK_SOURCE-$sample.json" - & $pwsh -NoLogo -NoProfile -NonInteractive -File $measureScript ` - -ModulePath $manifests[0].FullName -ScriptPath $scriptPath -ResultPath $resultPath @analysisArguments - if ($LASTEXITCODE -ne 0) { - throw "Benchmark sample $sample failed (exit code $LASTEXITCODE)." - } + $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 } @@ -221,7 +236,9 @@ jobs: 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: @@ -230,6 +247,8 @@ jobs: if: ${{ !cancelled() }} runs-on: ${{ matrix.os }} timeout-minutes: 10 + env: + BENCHMARK_REPETITIONS: ${{ inputs.repetitions }} strategy: fail-fast: false matrix: @@ -248,49 +267,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', 'perf') { + $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', 'perf') { + foreach ($candidate in $measurements[$source]) { + foreach ($property in 'InputSHA256', 'InputFileCount', 'SettingsSHA256', 'Recurse', + 'WorkloadRevision', 'PowerShellVersion', 'RunnerOS', 'Culture', 'UICulture', 'MetricsRequested') { + 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) + @($measurements.perf) + } + $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', 'perf') { + $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', 'perf') { + $samples = $measurements[$source] + $reference = if ($source -eq 'perf') { 'perf' } else { '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'), @('perf', 'upstream'), @('perf', 'fork'))) { + 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 += @( @@ -299,25 +357,24 @@ 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)" + "Optional metrics requested: $($baseline.MetricsRequested). Instrumented runs include measurement overhead and should not be compared with uninstrumented runs." '' - '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, full normalized diagnostics, and available lock/cache counters.' ) + foreach ($source in 'upstream', 'fork', 'perf') { + $sample = $measurements[$source][0] + $summary += "$source runner: $($sample.RunnerImage) $($sample.RunnerImageVersion); logical processors: $($sample.ProcessorCount); metrics available: $($sample.MetricsAvailable)" + $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 diff --git a/Engine/CommandInfoCache.cs b/Engine/CommandInfoCache.cs index af76f928e..e31139ada 100644 --- a/Engine/CommandInfoCache.cs +++ b/Engine/CommandInfoCache.cs @@ -25,6 +25,10 @@ internal class CommandInfoCache : IDisposable 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 +40,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. @@ -62,7 +66,7 @@ protected virtual void Dispose(bool disposing) // Always take the lock, also on the finalizer path, so that 'disposed' is never // published without the runspace being disposed along with it and so that the runspace // cannot be disposed while a lookup is in flight. - lock (_runspaceLock) + using (PerformanceTelemetry.EnterLock(_runspaceLock)) { if ( disposed ) { @@ -95,10 +99,14 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes var key = new CommandLookupKey(commandName, commandTypes); if (bypassCache) { + PerformanceTelemetry.Increment(ref PerformanceTelemetry.LookupBypasses); 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; @@ -115,6 +123,14 @@ public CommandInfo GetCommandInfo(string commandName, CommandTypes? commandTypes } } + private Lazy CreateLookup(string commandName, CommandTypes? commandTypes) + { + return new Lazy(() => + { + PerformanceTelemetry.Increment(ref PerformanceTelemetry.LookupMisses); + return GetCommandInfoInternal(commandName, commandTypes); + }); + } /// /// Get a CommandInfo object of the given command name @@ -145,7 +161,7 @@ private CommandInfo GetCommandInfoInternal(string cmdName, CommandTypes? command { // 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) + using (PerformanceTelemetry.EnterLock(_runspaceLock)) { if (disposed) { @@ -219,11 +235,13 @@ public Dictionary GetCommandParameters( // 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) + using (PerformanceTelemetry.EnterLock(_runspaceLock)) { // Dynamic parameter getters execute PowerShell code and mutate runspace state, // even though they look like ordinary property reads. - return disposed ? null : commandInfo?.Parameters; + if (disposed || commandInfo == null) return null; + PerformanceTelemetry.Increment(ref PerformanceTelemetry.MetadataQueries); + return commandInfo.Parameters; } } @@ -233,9 +251,71 @@ public Dictionary GetCommandParameters( public ReadOnlyCollection GetCommandParameterSets(string commandName) { var commandInfo = GetCommandInfo(commandName); - lock (_runspaceLock) + using (PerformanceTelemetry.EnterLock(_runspaceLock)) + { + if (disposed || commandInfo == null) return null; + PerformanceTelemetry.Increment(ref PerformanceTelemetry.MetadataQueries); + return commandInfo.ParameterSets; + } + } + + 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) + { + var command = GetCommandInfo(commandName, commandTypes, bypassCache); + var staticCmdlet = bypassCache ? null : GetStaticCmdlet(command); + if (disposed || command == null) return null; + if (staticCmdlet != null && _parameterSnapshots.TryGetValue(staticCmdlet, out var cached)) return cached; + + using (PerformanceTelemetry.EnterLock(_runspaceLock)) { - return disposed ? null : commandInfo?.ParameterSets; + if (disposed) return null; + if (staticCmdlet != null && _parameterSnapshots.TryGetValue(staticCmdlet, out cached)) return cached; + PerformanceTelemetry.Increment(ref PerformanceTelemetry.MetadataQueries); + 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) + { + var command = GetCommandInfo(commandName); + var staticCmdlet = GetStaticCmdlet(command); + if (disposed || command == null) return null; + if (staticCmdlet != null && _mandatoryParameters.TryGetValue(staticCmdlet, out var cached)) return cached; + + using (PerformanceTelemetry.EnterLock(_runspaceLock)) + { + if (disposed) return null; + if (staticCmdlet != null && _mandatoryParameters.TryGetValue(staticCmdlet, out cached)) return cached; + PerformanceTelemetry.Increment(ref PerformanceTelemetry.MetadataQueries); + int setCount = command.ParameterSets.Count; + var mandatory = new List(); + foreach (var parameter in command.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 +358,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..31d152cdd 100644 --- a/Engine/Helper.cs +++ b/Engine/Helper.cs @@ -294,7 +294,7 @@ public PSModuleInfo GetModuleManifest(string filePath, out IEnumerable psObj = null; // Test-ModuleManifest is not thread safe - lock (_testModuleManifestLock) + using (PerformanceTelemetry.EnterLock(_testModuleManifestLock)) { using (var ps = System.Management.Automation.PowerShell.Create()) { @@ -303,6 +303,7 @@ 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 +409,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 +443,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 => @@ -701,6 +715,19 @@ public ReadOnlyCollection GetCommandParameterSets(strin return CommandInfoCache.GetCommandParameterSets(name); } + /// Gets detached parameter facts; 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. + 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/PerformanceTelemetry.cs b/Engine/PerformanceTelemetry.cs new file mode 100644 index 000000000..c6a00c631 --- /dev/null +++ b/Engine/PerformanceTelemetry.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; + +namespace Microsoft.Windows.PowerShell.ScriptAnalyzer +{ + internal static class PerformanceTelemetry + { + private static volatile bool enabled; + public static bool Enabled { get => enabled; set => enabled = value; } + + internal static long LookupMisses, LookupBypasses, MetadataQueries, ManifestValidations; + private static long lockWaitTicks, lockHoldTicks; + + internal static void Increment(ref long counter) + { + if (Enabled) + { + Interlocked.Increment(ref counter); + } + } + + // Reset only between measurements, when no analysis is running. + public static void Reset() + { + Interlocked.Exchange(ref LookupMisses, 0); + Interlocked.Exchange(ref LookupBypasses, 0); + Interlocked.Exchange(ref MetadataQueries, 0); + Interlocked.Exchange(ref ManifestValidations, 0); + Interlocked.Exchange(ref lockWaitTicks, 0); + Interlocked.Exchange(ref lockHoldTicks, 0); + } + + public static Dictionary Snapshot() + { + return new Dictionary + { + { "LookupMisses", Interlocked.Read(ref LookupMisses) }, + { "LookupBypasses", Interlocked.Read(ref LookupBypasses) }, + { "MetadataQueries", Interlocked.Read(ref MetadataQueries) }, + { "ManifestValidations", Interlocked.Read(ref ManifestValidations) }, + { "LockWaitTicks", Interlocked.Read(ref lockWaitTicks) }, + { "LockHoldTicks", Interlocked.Read(ref lockHoldTicks) }, + }; + } + + internal static LockScope EnterLock(object syncRoot) => new LockScope(syncRoot); + + internal struct LockScope : IDisposable + { + private readonly object syncRoot; + private readonly bool measured; + private readonly long acquired; + + internal LockScope(object syncRoot) + { + this.syncRoot = syncRoot; + measured = Enabled; + long start = measured ? Stopwatch.GetTimestamp() : 0; + Monitor.Enter(syncRoot); + acquired = measured ? Stopwatch.GetTimestamp() : 0; + if (measured) + { + Interlocked.Add(ref lockWaitTicks, acquired - start); + } + } + + public void Dispose() + { + if (measured) + { + Interlocked.Add(ref lockHoldTicks, Stopwatch.GetTimestamp() - acquired); + } + Monitor.Exit(syncRoot); + } + } + } +} 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/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..e21b63db1 100644 --- a/Rules/UseCmdletCorrectly.cs +++ b/Rules/UseCmdletCorrectly.cs @@ -145,33 +145,10 @@ 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(); + IReadOnlyList mandatoryParameters; 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; - } - } - - if (count >= noOfParamSets) - { - mandatoryParameters.Add(pm); - } - } + mandatoryParameters = Helper.Instance.GetMandatoryParameterNames(cmdAst.GetCommandName()); } catch (Exception) { @@ -179,7 +156,7 @@ private bool MandatoryParameterExists(CommandAst cmdAst) return true; } - if (mandatoryParameters.Count == 0) + if (mandatoryParameters == null || mandatoryParameters.Count == 0) { return true; } @@ -188,8 +165,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; } @@ -255,4 +232,3 @@ public string GetSourceName() } - diff --git a/Rules/UseCorrectCasing.cs b/Rules/UseCorrectCasing.cs index a2ef3ad91..02724250b 100644 --- a/Rules/UseCorrectCasing.cs +++ b/Rules/UseCorrectCasing.cs @@ -119,11 +119,15 @@ public override IEnumerable AnalyzeScript(Ast ast, string file } var commandParameterAsts = commandAst.FindAll( - testAst => testAst is CommandParameterAst, true).Cast(); - Dictionary availableParameters; + testAst => testAst is CommandParameterAst, true).Cast().ToArray(); + if (commandParameterAsts.Length == 0) + { + continue; + } + IReadOnlyDictionary availableParameters; try { - availableParameters = Helper.Instance.GetCommandParameters(commandName); + availableParameters = Helper.Instance.GetCommandParameterSnapshot(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. @@ -143,7 +147,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)) @@ -172,11 +176,11 @@ 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) + private IReadOnlyDictionary GetParametersFromFreshCommandInfo(string commandName) { try { - return Helper.Instance.GetCommandParameters(commandName, bypassCache: true); + return Helper.Instance.GetCommandParameterSnapshot(commandName, bypassCache: true); } catch (Exception exception) when (exception is InvalidOperationException || exception is NullReferenceException) { 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..35b97d999 --- /dev/null +++ b/Tests/Build/MeasureScriptAnalyzerPerformance.tests.ps1 @@ -0,0 +1,98 @@ +# 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 + } +} diff --git a/Tests/Engine/AnalysisMetadataPerformance.tests.ps1 b/Tests/Engine/AnalysisMetadataPerformance.tests.ps1 new file mode 100644 index 000000000..8007a1de6 --- /dev/null +++ b/Tests/Engine/AnalysisMetadataPerformance.tests.ps1 @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe "Analysis metadata work" { + BeforeAll { + $null = Invoke-ScriptAnalyzer -ScriptDefinition 'Write-Output example' + $telemetry = [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper].Assembly.GetType( + 'Microsoft.Windows.PowerShell.ScriptAnalyzer.PerformanceTelemetry') + $casingSettings = @{ + IncludeRules = @('PSUseCorrectCasing') + Rules = @{ PSUseCorrectCasing = @{ Enable = $true; CheckKeyword = $false; CheckOperator = $false } } + } + $manifestRules = @('PSMissingModuleManifestField', 'PSUseToExportFieldsInManifest') + } + + BeforeEach { + $telemetry.GetMethod('Reset').Invoke($null, @()) + $telemetry.GetProperty('Enabled').SetValue($null, $true) + } + + AfterEach { + $telemetry.GetProperty('Enabled').SetValue($null, $false) + } + + It "does not query metadata for commands with no recursive parameter ASTs" { + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition 'get-item; write-output example' -Settings $casingSettings) + $diagnostics.Count | Should -Be 2 + $telemetry.GetMethod('Snapshot').Invoke($null, @())['MetadataQueries'] | Should -Be 0 + } + + It "preserves recursive casing checks for parameters nested in script blocks" { + $diagnostics = @(Invoke-ScriptAnalyzer -ScriptDefinition 'Get-Item { Unknown-SnapshotCommand -path value }' -Settings $casingSettings) + $diagnostics.Count | Should -Be 1 + $diagnostics[0].SuggestedCorrections[0].Text | Should -BeExactly 'Path' + $telemetry.GetMethod('Snapshot').Invoke($null, @())['MetadataQueries'] | Should -Be 1 + } + + It "does not query export metadata when no export command matches" { + $ast = [System.Management.Automation.Language.Parser]::ParseInput('function Test-Example {}', [ref]$null, [ref]$null) + [Microsoft.Windows.PowerShell.ScriptAnalyzer.Helper]::Instance.GetExportedFunction($ast).Count | Should -Be 0 + $telemetry.GetMethod('Snapshot').Invoke($null, @())['MetadataQueries'] | Should -Be 0 + } + + It "preserves mandatory parameter matching for