Repository navigation
$PSBPreference.Build.CompileModule can silently produce a module that exports nothing #201
Description
Found while building a sample consumer to test the AI migration prompt for #159. The consumer was written to look like an ordinary module; it was the tooling that broke.
Reproduction
A module with the standard scaffold .psm1 — a dot-sourcing loader, which is what almost every PowerShell module template generates:
# src/Probe.psm1
$public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction SilentlyContinue)
foreach ($import in $public) { . $import.FullName }
Export-ModuleMember -Function $public.BaseNameBuilt twice, changing only -Compile:
Compile=False exported commands: [Get-Thing]
Compile=True exported commands: []
Both builds succeed. No error, no warning from the build itself.
Mechanism
Build-PSBuildModule.ps1 reads the copied .psm1, blanks it, writes the concatenated *.ps1 files, then appends the original .psm1 content at the end:
$psm1Contents = Get-Content -Path $rootModule -Raw # the source .psm1
"" | Out-File -FilePath $rootModule # blank it
# ... header, then every file from CompileDirectories ...
$psm1Contents | Add-Content @addContentSplat # original appended LASTSo the compiled output is:
function Get-Thing { "thing" }
$public = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1" -ErrorAction SilentlyContinue)
foreach ($import in $public) { . $import.FullName }
Export-ModuleMember -Function $public.BaseNameCompile mode copies only *.psm1, *.psd1, *.ps1xml at depth 1 plus CopyDirectories, so the output has no Public/ directory:
out-compile-True/
Probe.psd1
Probe.psm1
$public is therefore empty and the appended line becomes Export-ModuleMember -Function @().
The cruel part: PowerShellBuild does its own job correctly and then loses. It writes the right answer into the manifest —
FunctionsToExport = "Get-Thing"
— but the effective export set is the intersection of FunctionsToExport and Export-ModuleMember, and the appended loader empties it.
Why this matters more than it looks
- The failure is silent and the build is green. The only visible symptom is downstream:
GenerateMarkdownemitsNo commands have been exported. Skipping markdown generation.— a warning, in a different task, that names a docs problem rather than an empty module. - It fires on the most common module shape there is. This is the scaffold that
Plaster, most templates, and this repository itself produce. - PowerShellBuild would do it to itself.
PowerShellBuild/PowerShellBuild.psm1has the identical loader shape and ends inExport-ModuleMember -Function $public.Basename. We only avoid it becauseCompileModuledefaults to$falseand we never turn it on. - Flipping
CompileModulefrom$falseto$trueis a one-line change a consumer makes expecting a packaging improvement. They get a module with zero commands and a passing build.
Not a regression
This is pre-existing behavior, not something the 1.0.0 cycle introduced, and CompileModule defaults to $false. Raising it now because 1.0.0 is a claim that the module is stable and supported, and a documented feature that silently produces a broken artifact sits awkwardly against that.
Options, in rough order of cost
- Verify the build output. After building, import the result and assert it exports what
FunctionsToExportclaims; warn or fail on a mismatch. Catches this and anything else that empties a module, and does not require guessing which appended lines are loader code. - Detect and warn. If compiling and the source
.psm1containsExport-ModuleMember, warn that it will run after the concatenated functions and may override the manifest. - Document it.
CompileHeader/CompileFooterexist partly for this. The README and the settings table say nothing about the appended.psm1needing to survive compilation — a consumer cannot currently learn this except by hitting it.
(1) is the one I would pick, since it is the only one that fails loudly rather than relying on the consumer reading something first. But whether any of it belongs in 1.0.0 is a scope call.
Related: #98 (Tests: Build-PSBuildModule) is open and has no coverage for compile mode at all — an existing test asserting the compiled module actually exports its functions would have caught this.
Activity
tablackburn commentedon Aug 28, 2026
Added to the v1.0.0 milestone (2026-08-28) and wired as a blocker of #160.
The reasoning is that this stops being optional the moment #98 starts. #98 is now in the release scope, and its stated scope includes replacing
tests/TestModule's one-line source.psm1with a realistic dot-sourcing loader and asserting on the built module's exported commands rather than its file text. The instant that lands, compile-mode tests fail on exactly this defect — so the fix decision falls inside 1.0.0 work whether or not the issue carries the milestone. Better that the tracking says so.That also means the three options in the description are no longer equally weighted. Option 1 — verify the build output by importing it and asserting it exports what
FunctionsToExportclaims — is close to what #98 has to build anyway, so the marginal cost of doing it in the product rather than only in the tests is small.Worth restating why this matters for a release that calls itself stable: the build is green while producing a module that exports nothing, and the only visible symptom appears in a different task (
GenerateMarkdownwarning that no commands were exported). A consumer flippingCompileModuleto$truegets a broken artifact and a passing build.