Build-PSBuildMarkdown ends with an unconditional Remove-Module $ModuleName in its finally. Remove-Module -Name removes every loaded module with that name, including a copy the caller loaded before the call and that this function never imported. The caller gets their session emptied out as a side effect of generating documentation.
What happens
$moduleInfo = Import-Module "$ModulePath/$ModuleName.psd1" -Global -Force -PassThru # line 54
try {
...
} catch {
Write-Error ($LocalizedData.FailedToGenerateMarkdownHelp -f $_)
} finally {
Remove-Module $ModuleName # line 152
}
The import is scoped by path; the removal is scoped by name. Nothing records what was loaded before the call, and nothing is put back.
Measured
A caller loads their own copy of a module, calls Build-PSBuildMarkdown against a different path with the same module name, and afterwards their copy is gone:
BEFORE instances=1
AFTER (different paths) instances=0
AFTER Get-Widget works? no
With the caller's copy and the documented copy at the same path, the effect is the same and the message is blunter:
BEFORE Get-Module PSBuildTestFixture -> 1 instance(s)
BEFORE Get-Widget works? yes
AFTER Get-Module PSBuildTestFixture -> 0 instance(s)
AFTER Get-Widget works? no: The term 'Get-Widget' is not recognized as a name of a cmdlet,
function, script file, or executable program.
Docs generated: 3 file(s)
The documentation is produced correctly. The session is left broken.
Three further facts that shape the fix, all measured:
Scoping the Remove-Module is not sufficient on its own. When the caller had loaded the same path, Import-Module -Global -Force at line 54 replaces their instance before the finally is ever reached:
same-path: instances=1; original instance object still present? False
So even a Remove-Module -ModuleInfo $moduleInfo would leave the caller with nothing, because the thing it removes is what used to be theirs. (When the paths differ, the two instances coexist — instances=2 — and only the name-based removal takes both.)
-Force is nevertheless load-bearing and must not simply be dropped. Without it, Import-Module on an already-loaded module is a no-op that returns the cached PSModuleInfo, reporting the previous build's exported commands:
v1 exports: Get-Alpha
re-import WITHOUT -Force reports: Get-Alpha
re-import WITH -Force reports: Get-Alpha, Get-Beta
$moduleInfo is passed straight to New-MarkdownCommandHelp -ModuleInfo, so without -Force the docs would silently document a stale command surface — a worse failure than this one, because it is invisible.
The zero-export early return unloads the caller's module too, despite generating nothing:
BEFORE: Get-Alpha -> alpha
AFTER : Get-Alpha -> gone
Markdown generated: 0 file(s)
That is the FunctionsToExport = @() path — the #201 shape — where the function warns NoCommandsExported and returns. return inside try still runs the finally.
Why it matters, and who it reaches
This is not an edge case reached by unusual configuration. It is on by default for essentially every consumer:
$PSBBuildDependency = @('StageFiles', 'BuildHelp')
$PSBBuildHelpDependency = @('GenerateMarkdown', 'GenerateMAML')
Build depends on BuildHelp depends on GenerateMarkdown, so any consumer running build.ps1 at all runs this, unless they override $PSBBuildDependency. A survey of 75 consumer psakefiles carried out during this investigation found 1 that does — the one quoted below. That figure has not been independently re-measured here; the default dependency chain above, and the PSDepend override, have been.
A real consumer has already hit it and worked around it. PowerShellOrg/PSDepend removed the documentation tasks from its build, with the reason in the file:
# Pre-set before -FromModule so PowerShellBuild 0.7.x's null-check doesn't override it.
# Skips BuildHelp (GenerateMarkdown) — doc generation is not needed in the test pipeline
# and Build-PSBuildMarkdown has a Remove-Module scope bug specific to PSDepend.
$PSBBuildDependency = @('StageFiles')
That is a consumer diagnosing this defect correctly, deciding it was not worth reporting, and paying for it by giving up generated documentation. It is not "specific to PSDepend" — it is specific to any build session that had the module loaded, which is the common case for an interactive ./build.ps1 in a module's own repository.
The symptom a consumer sees is a command that worked a moment ago no longer being recognised, in a session where nothing obviously removed it. The natural conclusions are "my build corrupted something" or "PowerShell is confused", not "the documentation task unloaded my module".
Options
- Record what was loaded and restore it. Capture
Get-Module -Name $ModuleName before the import, remove by -ModuleInfo rather than by name in the finally, and re-import anything that was previously loaded. This is the shape that actually works, because it addresses both halves — the over-broad removal and the -Force eviction — and it keeps -Force so the documented surface stays current. Cost: the restored module is a fresh import, not literally the caller's original instance, so a caller holding a PSModuleInfo reference still sees it go stale. That is a much smaller problem than the command disappearing.
- Leave the module loaded. Delete the
Remove-Module entirely and accept that generating documentation leaves the built module imported. Simplest possible change, and it makes the function's side effect additive rather than destructive. The cost is that a subsequent task in the same session runs against a module imported from the build output, which is usually what you want but is a real behaviour change and could mask a staleness problem elsewhere.
- Do the whole thing in a separate process. The function's own test file already does this —
Invoke-PSBuildCommandInJob in tests/fixtures/FixtureHelpers.psm1 exists specifically because these commands modify the session they run in. Moving the isolation into the function rather than into its tests makes the session hygiene the function's own responsibility. Most robust, most expensive, and it changes the error and progress reporting a consumer sees.
- Document it. Say in the README that
GenerateMarkdown unloads the named module. This is what the current situation amounts to, undocumented, and it is not a fix — but it is better than nothing if the change is judged too large for now.
(1) is the option I would take. It preserves every current behaviour a consumer relies on, including the -Force refresh, and it is contained entirely within this function.
A related, separable point: -Global on the import at line 54 is unnecessary. PlatyPS 1.0.3 resolves the module through the PSModuleInfo object passed as -ModuleInfo, not by name, so it does not need the module in the global session state. Measured — the generated markdown is byte-for-byte identical with -Global removed:
withGlobal: 3 file(s): Get-Widget.md, PSBuildTestFixture.md, Set-Widget.md
noGlobal : 3 file(s): Get-Widget.md, PSBuildTestFixture.md, Set-Widget.md
Get-Widget.md identical=True
PSBuildTestFixture.md identical=True
Set-Widget.md identical=True
Dropping -Global reduces the blast radius of the import, but it does not fix the eviction: the name-based Remove-Module in the finally still reaches the caller's copy regardless of which session state the function imported into. Worth doing, worth doing separately, and not a substitute for (1).
Related: #222, the same defect in a worse form in Test-PSBuildPester — same root cause, probably one change. #201 for the zero-export path referenced above.
Build-PSBuildMarkdownends with an unconditionalRemove-Module $ModuleNamein itsfinally.Remove-Module -Nameremoves every loaded module with that name, including a copy the caller loaded before the call and that this function never imported. The caller gets their session emptied out as a side effect of generating documentation.What happens
The import is scoped by path; the removal is scoped by name. Nothing records what was loaded before the call, and nothing is put back.
Measured
A caller loads their own copy of a module, calls
Build-PSBuildMarkdownagainst a different path with the same module name, and afterwards their copy is gone:With the caller's copy and the documented copy at the same path, the effect is the same and the message is blunter:
The documentation is produced correctly. The session is left broken.
Three further facts that shape the fix, all measured:
Scoping the
Remove-Moduleis not sufficient on its own. When the caller had loaded the same path,Import-Module -Global -Forceat line 54 replaces their instance before thefinallyis ever reached:So even a
Remove-Module -ModuleInfo $moduleInfowould leave the caller with nothing, because the thing it removes is what used to be theirs. (When the paths differ, the two instances coexist —instances=2— and only the name-based removal takes both.)-Forceis nevertheless load-bearing and must not simply be dropped. Without it,Import-Moduleon an already-loaded module is a no-op that returns the cachedPSModuleInfo, reporting the previous build's exported commands:$moduleInfois passed straight toNew-MarkdownCommandHelp -ModuleInfo, so without-Forcethe docs would silently document a stale command surface — a worse failure than this one, because it is invisible.The zero-export early return unloads the caller's module too, despite generating nothing:
That is the
FunctionsToExport = @()path — the #201 shape — where the function warnsNoCommandsExportedandreturns.returninsidetrystill runs thefinally.Why it matters, and who it reaches
This is not an edge case reached by unusual configuration. It is on by default for essentially every consumer:
Builddepends onBuildHelpdepends onGenerateMarkdown, so any consumer runningbuild.ps1at all runs this, unless they override$PSBBuildDependency. A survey of 75 consumer psakefiles carried out during this investigation found 1 that does — the one quoted below. That figure has not been independently re-measured here; the default dependency chain above, and the PSDepend override, have been.A real consumer has already hit it and worked around it.
PowerShellOrg/PSDependremoved the documentation tasks from its build, with the reason in the file:That is a consumer diagnosing this defect correctly, deciding it was not worth reporting, and paying for it by giving up generated documentation. It is not "specific to PSDepend" — it is specific to any build session that had the module loaded, which is the common case for an interactive
./build.ps1in a module's own repository.The symptom a consumer sees is a command that worked a moment ago no longer being recognised, in a session where nothing obviously removed it. The natural conclusions are "my build corrupted something" or "PowerShell is confused", not "the documentation task unloaded my module".
Options
Get-Module -Name $ModuleNamebefore the import, remove by-ModuleInforather than by name in thefinally, and re-import anything that was previously loaded. This is the shape that actually works, because it addresses both halves — the over-broad removal and the-Forceeviction — and it keeps-Forceso the documented surface stays current. Cost: the restored module is a fresh import, not literally the caller's original instance, so a caller holding aPSModuleInforeference still sees it go stale. That is a much smaller problem than the command disappearing.Remove-Moduleentirely and accept that generating documentation leaves the built module imported. Simplest possible change, and it makes the function's side effect additive rather than destructive. The cost is that a subsequent task in the same session runs against a module imported from the build output, which is usually what you want but is a real behaviour change and could mask a staleness problem elsewhere.Invoke-PSBuildCommandInJobintests/fixtures/FixtureHelpers.psm1exists specifically because these commands modify the session they run in. Moving the isolation into the function rather than into its tests makes the session hygiene the function's own responsibility. Most robust, most expensive, and it changes the error and progress reporting a consumer sees.GenerateMarkdownunloads the named module. This is what the current situation amounts to, undocumented, and it is not a fix — but it is better than nothing if the change is judged too large for now.(1) is the option I would take. It preserves every current behaviour a consumer relies on, including the
-Forcerefresh, and it is contained entirely within this function.A related, separable point:
-Globalon the import at line 54 is unnecessary. PlatyPS 1.0.3 resolves the module through thePSModuleInfoobject passed as-ModuleInfo, not by name, so it does not need the module in the global session state. Measured — the generated markdown is byte-for-byte identical with-Globalremoved:Dropping
-Globalreduces the blast radius of the import, but it does not fix the eviction: the name-basedRemove-Modulein thefinallystill reaches the caller's copy regardless of which session state the function imported into. Worth doing, worth doing separately, and not a substitute for (1).Related: #222, the same defect in a worse form in
Test-PSBuildPester— same root cause, probably one change. #201 for the zero-export path referenced above.