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.BaseName
Built 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 LAST
So 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.BaseName
Compile 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:
GenerateMarkdown emits No 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.psm1 has the identical loader shape and ends in Export-ModuleMember -Function $public.Basename. We only avoid it because CompileModule defaults to $false and we never turn it on.
- Flipping
CompileModule from $false to $true is 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
FunctionsToExport claims; 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
.psm1 contains Export-ModuleMember, warn that it will run after the concatenated functions and may override the manifest.
- Document it.
CompileHeader / CompileFooter exist partly for this. The README and the settings table say nothing about the appended .psm1 needing 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.
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:Built twice, changing only
-Compile:Both builds succeed. No error, no warning from the build itself.
Mechanism
Build-PSBuildModule.ps1reads the copied.psm1, blanks it, writes the concatenated*.ps1files, then appends the original.psm1content at the end:So the compiled output is:
Compile mode copies only
*.psm1,*.psd1,*.ps1xmlat depth 1 plusCopyDirectories, so the output has noPublic/directory:$publicis therefore empty and the appended line becomesExport-ModuleMember -Function @().The cruel part: PowerShellBuild does its own job correctly and then loses. It writes the right answer into the manifest —
— but the effective export set is the intersection of
FunctionsToExportandExport-ModuleMember, and the appended loader empties it.Why this matters more than it looks
GenerateMarkdownemitsNo commands have been exported. Skipping markdown generation.— a warning, in a different task, that names a docs problem rather than an empty module.Plaster, most templates, and this repository itself produce.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.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
CompileModuledefaults 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
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..psm1containsExport-ModuleMember, warn that it will run after the concatenated functions and may override the manifest.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.