From e3d847be9bde2df984be8b81747af36056317c69 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:30:51 -0400 Subject: [PATCH 1/2] docs(lefthook-powershell): correct the PSScriptAnalyzer failure mechanism The runner header, worker header, READMEs and test comment described a cross-target engine-state leak that process isolation fixes. The failure is a CommandInfo race between rules running in parallel inside one Invoke-ScriptAnalyzer call, and it reproduces on one file in a fresh process. Name the real cause, state that the per-target worker only keeps one failure from masking others, and document the triggers to remove until PowerShell/PSScriptAnalyzer#2206 ships. Closes #598 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LCEBhuXxZAT8K619tC6T5f --- components/lefthook-powershell/README.md | 16 +++++++++++++--- .../psscriptanalyzer-staged.ps1 | 6 ++++-- .../psscriptanalyzer-staged.test.ps1 | 6 +++--- .../psscriptanalyzer-target.ps1 | 5 ++--- components/psscriptanalyzer/README.md | 4 ++-- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/components/lefthook-powershell/README.md b/components/lefthook-powershell/README.md index 911acdd5..13931e2f 100644 --- a/components/lefthook-powershell/README.md +++ b/components/lefthook-powershell/README.md @@ -14,9 +14,16 @@ PSScriptAnalyzer policy remains owned by the `psscriptanalyzer` component; this adapter only shortens local feedback. It launches one fresh `pwsh -NoProfile -NonInteractive` worker per target, analyzes that target exactly once, continues through later targets to report all failures, and treats every -analyzer engine/rule error as a failed hook. This process boundary avoids -PSScriptAnalyzer 1.25.0's intermittent cross-target engine-state failure without -retrying, suppressing, or accepting a different result. +analyzer engine/rule error as a failed hook. No target is retried. + +PSScriptAnalyzer 1.25.0 runs rules in parallel against a shared CommandInfo +cache, so a single file in a fresh process can intermittently throw a +`NullReferenceException` ([PowerShell/PSScriptAnalyzer#1867][3], [#1708][4]). +The per-target worker keeps one failure from masking other targets; it does +not prevent the race. Until the upstream fix ([#2206][5]) ships in a release, +remove the triggers: keep `PSUseCorrectCasing` disabled, and drop +`Export-ModuleMember` from a `.psm1` that exports every function it defines +(without the call, a script module exports all its functions and aliases). The focused `psscriptanalyzer-staged.test.ps1` regression supplies a fake analyzer module to prove deterministic one-target/one-process isolation, then @@ -29,3 +36,6 @@ single-target [`Invoke-ScriptAnalyzer -Path ... -Settings ...` interface][2]. [1]: https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_pwsh [2]: https://learn.microsoft.com/powershell/module/psscriptanalyzer/invoke-scriptanalyzer +[3]: https://github.com/PowerShell/PSScriptAnalyzer/issues/1867 +[4]: https://github.com/PowerShell/PSScriptAnalyzer/issues/1708 +[5]: https://github.com/PowerShell/PSScriptAnalyzer/pull/2206 diff --git a/components/lefthook-powershell/psscriptanalyzer-staged.ps1 b/components/lefthook-powershell/psscriptanalyzer-staged.ps1 index c89d496a..5ab9837a 100644 --- a/components/lefthook-powershell/psscriptanalyzer-staged.ps1 +++ b/components/lefthook-powershell/psscriptanalyzer-staged.ps1 @@ -12,8 +12,10 @@ Lefthook runs) and passes it explicitly: Invoke-ScriptAnalyzer does not reliably auto-discover a root settings file when the analyzed path is in a subdirectory, so without this the consumer's ruleset would be silently ignored. Each target runs exactly once in its own fresh no-profile pwsh - worker. PSScriptAnalyzer 1.25.0 can leak engine state between sequential targets in one process and - intermittently throw a NullReferenceException even though every target passes in isolation. + worker. PSScriptAnalyzer 1.25.0 runs rules in parallel against a shared cached CommandInfo, which + intermittently throws a NullReferenceException on files that call Export-ModuleMember or when + PSUseCorrectCasing is enabled (PowerShell/PSScriptAnalyzer#1867, #1708; fixed upstream in #2206). + A fresh worker per target keeps one failure from masking other targets; it does not prevent the race. The PSScriptAnalyzer component owns the rules; this adapter only orchestrates isolated invocations. CI remains the authoritative gate and this lane is fast staged-file feedback. diff --git a/components/lefthook-powershell/psscriptanalyzer-staged.test.ps1 b/components/lefthook-powershell/psscriptanalyzer-staged.test.ps1 index 3993ac17..479c1fab 100644 --- a/components/lefthook-powershell/psscriptanalyzer-staged.test.ps1 +++ b/components/lefthook-powershell/psscriptanalyzer-staged.test.ps1 @@ -141,9 +141,9 @@ Export-ModuleMember -Function Invoke-ScriptAnalyzer ) Assert-Condition ($historicalHookFiles.Count -eq 6) 'The historical commit-hook regression must use six files.' - # The PSScriptAnalyzer 1.25.0 cross-target state failure is intermittent. Repeating the exact - # six-file hook shape made the old shared-process adapter fail reliably, while the fake-module - # assertion above deterministically proves each target now receives an isolated worker. + # The PSScriptAnalyzer 1.25.0 CommandInfo race is intermittent. Repeating the exact six-file hook + # shape made the old shared-process adapter fail reliably, while the fake-module assertion above + # deterministically proves each target now receives its own worker. foreach ($iteration in 1..8) { $real = Invoke-AdapterProcess -Files $historicalHookFiles $realSucceeded = $real.ExitCode -eq 0 diff --git a/components/lefthook-powershell/psscriptanalyzer-target.ps1 b/components/lefthook-powershell/psscriptanalyzer-target.ps1 index 7ee2081f..5ca55836 100644 --- a/components/lefthook-powershell/psscriptanalyzer-target.ps1 +++ b/components/lefthook-powershell/psscriptanalyzer-target.ps1 @@ -3,9 +3,8 @@ .SYNOPSIS Analyzes one PowerShell target in an isolated process for the staged-file adapter. .DESCRIPTION - This internal worker is launched once per target by psscriptanalyzer-staged.ps1. Process - isolation prevents PSScriptAnalyzer engine state from leaking between targets while preserving - fail-closed behavior: analyzer errors and findings both return nonzero, and no target is retried. + This internal worker is launched once per target by psscriptanalyzer-staged.ps1. It is + fail-closed: analyzer errors and findings both return nonzero, and no target is retried. .PARAMETER Target The one PowerShell file to analyze. .PARAMETER Settings diff --git a/components/psscriptanalyzer/README.md b/components/psscriptanalyzer/README.md index 6ebd2082..f99e01e4 100644 --- a/components/psscriptanalyzer/README.md +++ b/components/psscriptanalyzer/README.md @@ -25,5 +25,5 @@ Lefthook adapter invokes every staged target exactly once in a distinct process, continues after an engine error, and fails the overall hook. It then repeatedly starts the exact historical six-file no-profile hook contract, followed by the current staged set, against pinned PSScriptAnalyzer 1.25.0. This covers both the -`PSUseCorrectCasing` exclusion for issue #1708 and the separate intermittent -cross-target engine-state failure that requires process isolation. +`PSUseCorrectCasing` exclusion for issue #1708 and the intermittent CommandInfo +race (issue #1867), which a per-target worker contains but does not prevent. From da3d686013ea5d396e995effe2bf73a7fa9fa877 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:30:52 -0400 Subject: [PATCH 2/2] docs(lefthook-powershell): scope the Export-ModuleMember advice and drop it from the fixture The README advice now covers aliases and says what a module with private members does instead. The get-command regression fixture exported its one function explicitly, which is the race trigger the README tells consumers to remove; without the call the module exports the same function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LCEBhuXxZAT8K619tC6T5f --- components/lefthook-powershell/README.md | 6 ++++-- .../fixtures/get-command/CommandLookup.psm1 | 2 -- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/lefthook-powershell/README.md b/components/lefthook-powershell/README.md index 13931e2f..fd1875f0 100644 --- a/components/lefthook-powershell/README.md +++ b/components/lefthook-powershell/README.md @@ -22,8 +22,10 @@ cache, so a single file in a fresh process can intermittently throw a The per-target worker keeps one failure from masking other targets; it does not prevent the race. Until the upstream fix ([#2206][5]) ships in a release, remove the triggers: keep `PSUseCorrectCasing` disabled, and drop -`Export-ModuleMember` from a `.psm1` that exports every function it defines -(without the call, a script module exports all its functions and aliases). +`Export-ModuleMember` from a `.psm1` that exports every function and alias it +defines (without the call, a script module exports all its functions and +aliases). A module that hides private members keeps the call, or moves its +export list into a module manifest. The focused `psscriptanalyzer-staged.test.ps1` regression supplies a fake analyzer module to prove deterministic one-target/one-process isolation, then diff --git a/components/lefthook-powershell/fixtures/get-command/CommandLookup.psm1 b/components/lefthook-powershell/fixtures/get-command/CommandLookup.psm1 index dd07bd7c..1dbe5ed3 100644 --- a/components/lefthook-powershell/fixtures/get-command/CommandLookup.psm1 +++ b/components/lefthook-powershell/fixtures/get-command/CommandLookup.psm1 @@ -13,5 +13,3 @@ function Get-ExternalToolPath { } return $null } - -Export-ModuleMember -Function Get-ExternalToolPath