test: Cover Build-PSBuildModule and warn on Export-ModuleMember when compiling - #205
Merged
Merged
Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
tests/Build-PSBuildModule.tests.ps1, dedicated coverage for the last public functionthat had none of its own: both build modes,
CompileHeader,CompileFooter,CompileScriptHeader,CompileScriptFooter,CompileDirectories,CopyDirectories,Exclude,ReadMePathandCulture, and theFunctionsToExportmanifest update..psm1callsExport-ModuleMembernow says so, instead of silently producing a module that exportsnothing.
tests/build.tests.ps1, whose compile-mode context asserted only onfile text, and makes
tests/TestModule's fixture realistic enough that those assertions canfail.
the
Excludefilter aborted the loop it was filtering, and compiling from a different drivethan 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-PSBuildModulelooked covered.tests/build.tests.ps1has aContext '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.psm1was a single line,# I'm some code in the src PSM1. Itcarries 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
.psm1callsExport-ModuleMember,Build-PSBuildModulenowwarns 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 — wasconsidered 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-ModuleMemberonly where it begins a line. That was not the firstdraft: 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 nowpins 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:
Public/andPrivate/are absent, and never callsExport-ModuleMemberExportedFunctionsafter importing it — the assertion the suite was missingFunctionsToExportis still correct, and the built module exports nothing — current, known behavior, documented as such in the testA third context builds the naive loader without
-Compileand asserts it exports itsfunctions 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:
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.ps1andtests/TestModule's one-line.psm1still in place:The build staged
Public/,Private/, the manifest and the root module correctly, and produceda 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
.psm1with 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.
Excludeaborted the loop it was filtering.Remove-ExcludedItemskipped an excluded itemwith a labeled
breakaimed at the loop over its whole input, so a caller passing acollection lost every item after the first excluded one, and on Windows PowerShell 5.1 the
breakescaped the function entirely and aborted whatever loop the caller was running. Thebuild 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-InputObjectgot back a list of nulls: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 cannotalways 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 thatcannot climb past that root. Neither reads back, so
Get-Contentcontributed nothing for everyfile and the compiled
.psm1came 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 onC:. It was then reproduced locallyunder 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:
(The message is Pester's; it is what a failing
Get-Contentinside that pipeline surfaces as on5.1, and it is why the first fix attempt went after the labeled
break. That fix is still righton its own merits, but it was not this.)
Something reported rather than fixed
Build-PSBuildModulewritesabout_<ModuleName>.help.txtfromReadMePathinside the branchthat creates the culture directory:
So a build whose output already has that directory writes no about help file at all, silently.
It is reachable in compile mode whenever
CopyDirectoriesnames the culture directory. Leftalone 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 Teston this branch: 551 passed, 0 failed, 3 skipped. Baseline onmainis 503 passed, 0 failed, 3 skipped.3 skipped.
main, none inBuild-PSBuildModule.ps1orRemove-ExcludedItem.ps1.tests/LocalizedData.tests.ps1covers the new message string: it asserts every key the modulereads 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