Skip to content

$PSBPreference.Build.CompileModule can silently produce a module that exports nothing #201

Description

@tablackburn

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

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions