Skip to content

test: Cover Build-PSBuildModule and warn on Export-ModuleMember when compiling - #205

Merged
tablackburn merged 5 commits into
mainfrom
test/98-build-psbuildmodule-coverage
Aug 28, 2026
Merged

test: Cover Build-PSBuildModule and warn on Export-ModuleMember when compiling#205
tablackburn merged 5 commits into
mainfrom
test/98-build-psbuildmodule-coverage

Conversation

@tablackburn

@tablackburn tablackburn commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds tests/Build-PSBuildModule.tests.ps1, dedicated coverage for the last public function
    that had none of its own: both build modes, CompileHeader, CompileFooter,
    CompileScriptHeader, CompileScriptFooter, CompileDirectories, CopyDirectories,
    Exclude, ReadMePath and Culture, and the FunctionsToExport manifest update.
  • Fixes $PSBPreference.Build.CompileModule can silently produce a module that exports nothing #201 with a warning: compiling a module whose source .psm1 calls
    Export-ModuleMember now says so, instead of silently producing a module that exports
    nothing.
  • Closes the blind spot in tests/build.tests.ps1, whose compile-mode context asserted only on
    file text, and makes tests/TestModule's fixture realistic enough that those assertions can
    fail.
  • Fixes two further defects the new coverage found, both on the compile path, both silent:
    the Exclude filter aborted the loop it was filtering, and compiling from a different drive
    than the module source produced a module with none of its functions in it.

Closes #98. Closes #201.

Why #98 and #201 are one change

Build-PSBuildModule looked covered. tests/build.tests.ps1 has a Context 'Compile module'
with eight assertions — and every one of them reads the text of the file that was written. File
text cannot tell a module that exports its commands from one that exports none, which is exactly
what #201 is. The tests could not be written honestly without confronting #201, and #201's fix
had no test without #98.

tests/TestModule's source .psm1 was a single line, # I'm some code in the src PSM1. It
carries no loader, so the fixture structurally could not exhibit #201 — which is why the
existing suite passed over it. It is now a guarded dot-sourcing loader.

The fix for #201 is a warning, not a behavior change

In compile mode, if the source .psm1 calls Export-ModuleMember, Build-PSBuildModule now
warns that the call runs after the concatenated functions and can override the manifest. The
string is in PowerShellBuild/en-US/Messages.psd1.

Importing the built module from inside Build-PSBuildModule — option 1 in the issue — was
considered and rejected: importing during a build has side effects and can fail for reasons
unrelated to packaging, which would turn a packaging check into a new class of build failure.

The check matches Export-ModuleMember only where it begins a line. That was not the first
draft: a plain substring match false-positived on the guarded fixture's own comment explaining
why it deliberately does not call Export-ModuleMember. The suite caught it, and a test now
pins it.

Two fixtures, because the fix only warns

Because the warning does not change what gets built, a compiled module built from a naive
scaffold loader still exports nothing. So "the compiled module exports its functions" cannot be
asserted against such a fixture. The tests use two source root modules, and the difference
between them is the whole of #201:

Fixture Shape What is asserted
Guarded loader Does nothing when Public/ and Private/ are absent, and never calls Export-ModuleMember The built module exports its public functions, read from ExportedFunctions after importing it — the assertion the suite was missing
Naive scaffold loader The shape Plaster and most templates generate The warning is emitted, the manifest's FunctionsToExport is still correct, and the built module exports nothing — current, known behavior, documented as such in the test

A third context builds the naive loader without -Compile and asserts it exports its
functions and emits no warning: nothing is wrong with the loader, compilation is what breaks it.

Red before green

The #201 warning. With the new test file in place and no product change, the two warning
assertions were the only failures:

Context Building with compilation from a scaffold loader
  [-] Warns that the source root module calls Export-ModuleMember
      Expected like wildcard '*Export-ModuleMember*' to match <empty>, but it did not match.
  [-] Names the source root module in the warning
      Expected like wildcard '*...\compiled-scaffold\PSBuildTestFixture\PSBuildTestFixture.psm1*'
      to match <empty>, but it did not match.
  [+] Still writes the public function names into FunctionsToExport
  [+] Builds a module that exports nothing
Tests Passed: 37, Failed: 2

Note the last two passing lines: that is #201 reproduced. The manifest is right, and the module
exports nothing anyway. After adding the warning: Tests Passed: 39, Failed: 0.

The export assertion. With the new export assertions added to tests/build.tests.ps1 and
tests/TestModule's one-line .psm1 still in place:

Context Dot-sourced module
  [-] Exports its public function
      Expected 'Get-HelloWorld', but got $null.
Tests Passed: 20, Failed: 2

The build staged Public/, Private/, the manifest and the root module correctly, and produced
a module that exported nothing — and every existing assertion in that file passed. That is the
blind spot, in one line. After replacing the fixture's .psm1 with a guarded loader:
Tests Passed: 22, Failed: 0.

Two more defects, both found by this coverage

Neither was known before; both are on the compile path and both fail silently. Each is one
commit, with its own test.

Exclude aborted the loop it was filtering. Remove-ExcludedItem skipped an excluded item
with a labeled break aimed at the loop over its whole input, so a caller passing a
collection lost every item after the first excluded one, and on Windows PowerShell 5.1 the
break escaped the function entirely and aborted whatever loop the caller was running. The
build reaches this one item at a time through the pipeline, where it is invisible; the new test
calls it with a collection. Before the fix it also exposed a second defect in the same four
lines — the loop matched and collected $_ rather than $item, so a caller using
-InputObject got back a list of nulls:

[-] Keeps the items that follow an excluded one
    Expected a collection @('Get-Widget.ps1', 'Test-WidgetName.ps1') with length 2,
    but got a collection @($null) with length 1.

Compiling across drives produced a module with no functions in it. The compile loop read
each function file through Resolve-Path $_.FullName -Relative, and a current location cannot
always express another path relatively. On Windows PowerShell 5.1 a source on another drive
comes back as .\C:\..., and a source outside the current PSDrive's root as a ..\ path that
cannot climb past that root. Neither reads back, so Get-Content contributed nothing for every
file and the compiled .psm1 came out with its headers and footers and none of the functions —
and the build reported success. PowerShell 7 returns the absolute path in the same situation,
which is why it only ever showed up on 5.1.

This one was found by CI: the Windows PowerShell 5.1 job failed all three compiled contexts,
with the checkout on D: and the Pester test drive on C:. It was then reproduced locally
under Windows PowerShell 5.1 with a PSDrive whose root does not contain the source, producing
the identical error, and that is what the new regression test uses:

[-] Context Compiling from a current location that cannot express the source path failed
    InvalidOperationException: A 'break' or 'continue' statement with a label that does not
    match any enclosing loop escaped from your code.

(The message is Pester's; it is what a failing Get-Content inside that pipeline surfaces as on
5.1, and it is why the first fix attempt went after the labeled break. That fix is still right
on its own merits, but it was not this.)

Something reported rather than fixed

Build-PSBuildModule writes about_<ModuleName>.help.txt from ReadMePath inside the branch
that creates the culture directory:

if (-not (Test-Path $culturePath -PathType Container)) {
    New-Item $culturePath -Type Directory -Force > $null
    Copy-Item @copyItemSplat
}

So a build whose output already has that directory writes no about help file at all, silently.
It is reachable in compile mode whenever CopyDirectories names the culture directory. Left
alone here because fixing it is a user-facing behavior change outside the scope of #98 and #201;
a test pins the current behavior with a comment saying so, so a later fix has somewhere obvious
to land. Worth its own issue.

Test plan

  • ./build.ps1 -Task Test on this branch: 551 passed, 0 failed, 3 skipped. Baseline on
    main is 503 passed, 0 failed, 3 skipped.
  • CI is green on all five legs, including Windows PowerShell 5.1: 548 passed, 0 failed,
    3 skipped
    .
  • PSScriptAnalyzer reports the same four pre-existing warnings as main, none in
    Build-PSBuildModule.ps1 or Remove-ExcludedItem.ps1.
  • tests/LocalizedData.tests.ps1 covers the new message string: it asserts every key the module
    reads is one it ships.

Breaking changes

None. Compile mode produces the same files it did before, other than in the two cases where it
was producing broken ones.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE

…compiling

Build-PSBuildModule was one of two public functions with no coverage of its
own. It looked covered: build.tests.ps1 has a compile-mode context with eight
assertions. Every one of them reads file text, and file text cannot tell a
module that exports its commands from one that exports none. That blind spot
is exactly the shape of #201.

Compile mode appends the source .psm1 after the concatenated function files
and copies no Public/ directory to the output, so the dot-sourcing loader
almost every module template generates discovers nothing and its
Export-ModuleMember call becomes Export-ModuleMember -Function @(). A
module's effective exports are the intersection of that call and
FunctionsToExport, so the manifest is right and the loader wins: zero
commands, and a green build.

The fix is a warning, not a behavior change. Importing the built module from
inside Build-PSBuildModule was considered and rejected -- importing during a
build has side effects and can fail for reasons unrelated to packaging,
turning a packaging check into a new class of build failure. The check matches
Export-ModuleMember only where it begins a line, so a comment explaining why a
loader deliberately does not call it is not reported as a call.

Because the fix only warns, a compiled module built from a naive scaffold
loader still exports nothing, so the tests use two fixtures rather than one. A
loader guarded to do nothing when the function directories are absent survives
compilation, and against it the tests assert the built module actually exports
its functions, by importing it and reading ExportedCommands. A naive scaffold
loader gets the warning asserted instead, plus an assertion that the result
exports nothing, documented in the test as current known behavior.

tests/TestModule's source .psm1 was a single comment line, which is why the
existing compile-mode context could not fail. It is now a guarded loader, and
build.tests.ps1 imports what it built in both contexts. Against the old
one-line fixture the new dot-sourced export assertion fails: the build staged
Public/ and Private/ correctly and produced a module exporting nothing, which
no assertion in that file could see.

Closes #98
Closes #201

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
Copilot AI lite review requested due to automatic review settings August 28, 2026 15:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

Test Results

    4 files  ±  0    849 suites  +64   2m 37s ⏱️ -17s
  559 tests + 51    556 ✅ + 51   3 💤 ±0  0 ❌ ±0 
2 219 runs  +204  2 147 ✅ +204  72 💤 ±0  0 ❌ ±0 

Results for commit 9874e97. ± Comparison against base commit b3bbe9e.

♻️ This comment has been updated with latest results.

tablackburn and others added 4 commits August 28, 2026 11:50
Windows PowerShell 5.1 CI on the previous commit failed all three compiled
contexts of the new Build-PSBuildModule coverage with Pester's

    A 'break' or 'continue' statement with a label that does not match any
    enclosing loop escaped from your code.

Remove-ExcludedItem holds the module's only labeled break, on the compile
path, and it was aimed at the wrong loop: it ended the loop over the whole
input rather than skipping the one excluded item. Called through the pipeline
that is invisible, because each process block invocation carries a single
item, which is the only way the build reaches it. Called with a collection it
is not: everything after the first excluded item was dropped, and on Windows
PowerShell 5.1 the break escaped the function altogether and aborted whatever
loop the caller was running, silently and with no error.

Replacing the labeled break with a flag keeps all flow control inside the
function. The same rewrite fixes a second defect in the same four lines: the
loop matched and collected $_ rather than $item, so a caller that passed
-InputObject instead of piping got back a list of nulls. The new test failed
on exactly that before the fix -- "got a collection @($null) with length 1"
-- and passes after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
The Windows PowerShell 5.1 CI job still failed every compiled context after the
labeled-break fix, with the same misleading Pester message about an escaping
labeled break. The real cause is one line above it in the same loop, which
resolved each function file to a path relative to the current location before
reading it.

Resolve-Path -Relative answers relative to the current location, and a current
location cannot always express another path relatively. On Windows PowerShell
5.1 a source file on another drive comes back with the drive letter embedded in
a relative path, and a source outside the current PSDrive's root as a parent
path that cannot climb past that root; neither can be read back. Get-Content
then contributed nothing for every file, so the compiled root module came out
holding its headers and footers and none of the functions, and the build
reported success. PowerShell 7 returns the absolute path in the same situation,
which is why this only ever showed up on 5.1.

CI is laid out exactly this way, with the checkout on one drive and the Pester
test drive on another, so the new coverage was the first thing to run the
compile path across drives and see it.

Reproduced locally under Windows PowerShell 5.1 with a PSDrive whose root does
not contain the source, which produces the identical Pester error, and pinned
by a new context that builds from such a location and asserts the functions are
in the compiled module. Nothing else needed the relative path: it was used for
the verbose message and to read the file, and the full path serves both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
instructions/repository-specific.instructions.md keeps a table of every
test file and what it covers. #188 corrected that table when it listed 5
of 15 files; adding a test file without adding a row puts it straight back
out of date.

Caught by the code review of this pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
…ral path

Two findings from the cloud review of #205, both verified before fixing.

The Export-ModuleMember warning matched text with a line-anchored regex.
That gets the single-line # comment right, because the # breaks the
anchor, and still fires inside a <# #> block or a here-string, because a
regex cannot tell that the line it anchored on is not code. Measured:
both false-positive, the # comment correctly does not. The warning states
a fact and tells the consumer to guard or remove the call, so reported
against a comment it sends them hunting for a call that does not exist.
Detection now parses the root module and looks for a CommandAst, which
ignores comments and here-strings by construction.

Get-Content read the source files with -Path. The value is a FullName, and
-Path reads [ ] * ? as wildcards, so a file named Get-Widget[legacy].ps1
resolves to nothing and is dropped from the compiled module while the
build still succeeds. Measured: -Path reads 0 lines from a bracketed path
where -LiteralPath reads it correctly. That is the same silent-drop
outcome this full-path read was written to prevent, reached a different
way, and -LiteralPath is what the rest of the module already uses for
FileInfo input.

Both tests fail against the unfixed code, each failing only its own case.

The wildcard test targets a bracketed file name rather than a bracketed
directory. A bracketed directory reproduces it too but cannot be set up:
New-ModuleManifest has no -LiteralPath.

Noted while writing these: Build-PSBuildModule -Compile with no
CompileDirectories compiles the current working directory tree, because
the parameter defaults to @() and Get-ChildItem -Path @() falls back to
the current location. The first draft of the wildcard test hit it and
passed for the wrong reason. Every other compile context in this file
passes CompileDirectories explicitly, as these two now do. Reported
separately rather than changed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
@tablackburn
tablackburn merged commit 6788d22 into main Aug 28, 2026
9 checks passed
@tablackburn
tablackburn deleted the test/98-build-psbuildmodule-coverage branch August 28, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$PSBPreference.Build.CompileModule can silently produce a module that exports nothing Tests: Build-PSBuildModule

2 participants