From 89a90aaa9c896412ff238e8b8ace3b0b068a748b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 16:50:11 +0200 Subject: [PATCH 1/2] Prove which module declaration keeps a script's exit code Three cases run a child script that prints RAN and exits 7, and assert on the child process's real exit code: an explicit Import-Module returns 7, a '#Requires -Version' line returns 7, and a '#Requires -Modules' line runs the script but does not return 7. Asserting RAN as well means a case cannot pass because the child never started. Pointing the '#Requires -Modules' case at the explicit-import declaration turns it red, so it measures the difference between the two forms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Requires-Modules.Tests.ps1 | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Requires-Modules.Tests.ps1 b/tests/Requires-Modules.Tests.ps1 index 603fcc2..08ae752 100644 --- a/tests/Requires-Modules.Tests.ps1 +++ b/tests/Requires-Modules.Tests.ps1 @@ -9,6 +9,10 @@ `#Requires` line and runs it in a child PowerShell; the line is enforced by the engine, so the child prints `SATISFIED` only when the requirement resolves. + The last context covers a side effect rather than a resolution rule: whether the child returns + the exit code it asked for. Those cases write a script that prints `RAN` and exits 7, and assert + on the child process's real exit code — so a case cannot pass because the script never ran. + Run: Invoke-Pester -Path ./tests/Requires-Modules.Tests.ps1 Uses the installed Pester as the sample module, so it is independent of the exact 6.x version. #> @@ -32,6 +36,22 @@ Describe '#Requires -Modules version specification' { } [bool]($output -match 'SATISFIED') } + + function Get-DeclarationExitCode { + param([Parameter(Mandatory)][AllowEmptyString()][string] $Declaration) + $file = Join-Path ([IO.Path]::GetTempPath()) ("requires_exit_" + [guid]::NewGuid().ToString('N') + '.ps1') + "$Declaration`r`nWrite-Output 'RAN'`r`nexit 7" | Set-Content -LiteralPath $file -Encoding utf8 + try { + $output = & $script:pwsh -NoProfile -File $file 2>&1 | Out-String + $code = $LASTEXITCODE + } finally { + Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue + } + [pscustomobject]@{ + Ran = [bool]($output -match 'RAN') + ExitCode = $code + } + } } Context 'Version range — floor plus wildcard ceiling' { @@ -66,4 +86,22 @@ Describe '#Requires -Modules version specification' { Test-RequiresSatisfied "@{ ModuleName = 'Pester'; ModuleVersion = '$major.0.0'; MaximumVersion = '$major.*' }" | Should -BeTrue } } + + Context 'Exit code — how the dependency is declared decides whether a verdict survives' { + It 'An explicit Import-Module returns the exit code the script asked for' { + $result = Get-DeclarationExitCode -Declaration "Import-Module -Name Pester -MinimumVersion $major.0.0 -MaximumVersion $major.*" + $result.Ran | Should -BeTrue + $result.ExitCode | Should -Be 7 + } + It 'A #Requires -Modules line runs the script but does not return the exit code it asked for' { + $result = Get-DeclarationExitCode -Declaration "#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '$major.0.0'; MaximumVersion = '$major.*' }" + $result.Ran | Should -BeTrue + $result.ExitCode | Should -Not -Be 7 + } + It 'A #Requires -Version line is not involved — the exit code survives it' { + $result = Get-DeclarationExitCode -Declaration '#Requires -Version 7.0' + $result.Ran | Should -BeTrue + $result.ExitCode | Should -Be 7 + } + } } From 123ba4285ae59e1d88baf8bd657a7078b38c9795 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 16:51:29 +0200 Subject: [PATCH 2/2] Warn that a #Requires -Modules line can discard a script's exit code Module Requirements covered every version-specification form and nothing about the one side effect that breaks a gate script: a script declaring its modules that way can exit 0 whatever it exits with, so the gate reports success on a failing run. Record the reproduction, the rule for an entry script whose exit code is the answer, and the boundaries of what was actually observed - including that the mechanism is unknown, so the guidance is a shape to avoid rather than a claim about how #Requires works. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PowerShell/Requires-Modules.md | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/docs/Coding-Standards/PowerShell/Requires-Modules.md b/src/docs/Coding-Standards/PowerShell/Requires-Modules.md index ca6c69c..50f4971 100644 --- a/src/docs/Coding-Standards/PowerShell/Requires-Modules.md +++ b/src/docs/Coding-Standards/PowerShell/Requires-Modules.md @@ -32,6 +32,41 @@ Other rules: - `GUID` pins module *identity*, which is orthogonal to the version — a wrong GUID blocks even a version match, and omitting it is fine. It is a supply-chain control, not part of the version lock (see [Security → Supply chain](../Security.md#supply-chain)). - Requirements are enforced by the engine at **parse/discovery time**: if no installed module satisfies the specification, the script is not run at all. +## An entry script that reports a verdict imports its modules itself + +`#Requires -Modules` is the right way for a **test file, a module, or a function library** to declare what it needs. It is the wrong way for an **entry script whose exit code is the answer** — a test gate, a validation script, a hook — because the declaration can cost the script that exit code. + +```powershell +# gate.ps1 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } +Write-Output 'ran' +exit 7 +``` + +```console +$ pwsh -NoProfile -File gate.ps1 +ran +$ echo $LASTEXITCODE +0 +``` + +The script runs to completion and executes its `exit`; only the code is lost. A gate written this way reports success no matter what it found — and because the failure is invisible in a passing run, it is caught only by [proving the check can fail](../Testing.md#prove-the-test-can-fail). + +Import the module instead, with the same range the specification would have carried: + +```powershell +Import-Module -Name Pester -MinimumVersion 6.0.0 -MaximumVersion 6.* +``` + +What was observed, and what was not: + +- It reproduces with Pester — name form and hashtable form, version `5.8.0` and `6.0.1` alike. It did **not** reproduce with `PSScriptAnalyzer`, `PSReadLine`, `Microsoft.PowerShell.PSResourceGet`, `Microsoft.PowerShell.Management`, or a throwaway module written for the test, all of which return the code the script asked for. +- `#Requires -Version` is not involved; only the `-Modules` parameter is. +- `pwsh -Command ". ./gate.ps1"` — the form [GitHub Actions](../GitHub-Actions.md) uses for `shell: pwsh` — collapses every non-zero exit to `1` whether or not the declaration is present, so a CI step still fails. The false green belongs to `pwsh -File`, which is how a person runs the script by hand. +- Why it is specific to one module is **not known**. The rule above is therefore written as a shape to avoid, not as a claim about how `#Requires` works. + +The `#Requires -Modules` lines inside `*.Tests.ps1` files stay as they are. Pester runs a test file as a container, not as an entry script, so no exit code is at stake there. + ## Choosing the tightness (risk appetite) Match the constraint to how much drift you can safely absorb: @@ -48,9 +83,10 @@ Every row above is backed by an executable Pester test, [`tests/Requires-Modules Invoke-Pester -Path ./tests/Requires-Modules.Tests.ps1 ``` -It proves, among the eight cases: +It proves, among the eleven cases: - The **major lock** (`ModuleVersion = 'N.0.0'; MaximumVersion = 'N.*'`) resolves to the installed `N.x`. - The **wildcard ceiling is enforced** — a ceiling below the floor is unsatisfiable (so `6.*` genuinely blocks 7.x). - An **exact** `RequiredVersion` that isn't installed does **not** resolve (why exact pins are fragile). - A **wrong GUID** blocks an otherwise-matching module, while **omitting** the GUID still resolves (identity is optional and orthogonal to version). +- An **explicit `Import-Module`** returns the exit code the script asked for, and a **`#Requires -Modules` declaration does not** — the last three cases run a child script that prints `RAN` and exits `7`, and assert on the child process's real exit code, so a case cannot pass because the script never ran.