From 701e5d35044cc766ffd15b5056345be7e8400157 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 11:28:20 -0400 Subject: [PATCH 1/5] test: Cover Build-PSBuildModule and warn on Export-ModuleMember when 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 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- CHANGELOG.md | 18 + .../Public/Build-PSBuildModule.ps1 | 21 + PowerShellBuild/en-US/Messages.psd1 | 1 + tests/Build-PSBuildModule.tests.ps1 | 579 ++++++++++++++++++ tests/TestModule/TestModule/TestModule.psm1 | 23 +- tests/build.tests.ps1 | 36 ++ 6 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 tests/Build-PSBuildModule.tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b6dd4..f0d94c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,24 @@ Everything below is the detail, one entry per issue. ### Fixed +- [**#201**](https://github.com/psake/PowerShellBuild/issues/201) + `$PSBPreference.Build.CompileModule = $true` now warns when the source + `.psm1` calls `Export-ModuleMember`. Compiling appends the source root + module after the concatenated function files, and compile mode copies no + `Public/` directory to the output, so the dot-sourcing loader that almost + every module template generates discovers nothing and the appended call + becomes `Export-ModuleMember -Function @()`. A module's effective exports + are the intersection of that call and `FunctionsToExport`, so the built + module exported nothing while the manifest correctly named every public + function, and the build reported success. The only previous symptom was + `No commands have been exported. Skipping markdown generation.` from a + later task, which names a documentation problem rather than an empty + module. The build still produces the same files; what changes is that it + now tells you. Guard the call so it does nothing when the function + directories are absent, or remove it and let `FunctionsToExport` govern + the export set. A mention of `Export-ModuleMember` in a comment does not + trigger the warning. + - [**#193**](https://github.com/psake/PowerShellBuild/issues/193) `$PSBPreference.Sign.SkipCertificateValidation` now does something. It was read only by `psakeFile.ps1`, and even there `Get-PSBuildCertificate` consulted diff --git a/PowerShellBuild/Public/Build-PSBuildModule.ps1 b/PowerShellBuild/Public/Build-PSBuildModule.ps1 index e8373d1..42c542f 100644 --- a/PowerShellBuild/Public/Build-PSBuildModule.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildModule.ps1 @@ -132,6 +132,27 @@ function Build-PSBuildModule { # Grab the contents of the copied over PSM1 # This will be appended to the end of the finished PSM1 $psm1Contents = Get-Content -Path $rootModule -Raw + + # Because that content is appended last, an Export-ModuleMember call inside it runs + # after the concatenated functions, and the module's effective export set is the + # intersection of that call and FunctionsToExport in the manifest. Compiling copies no + # function directories to the output, so the scaffold loader that almost every module + # template generates discovers nothing and exports nothing, while the manifest still + # names every public function and the build still succeeds. Warned about rather than + # rewritten: what the consumer's root module should do instead depends on the module. + # See psake/PowerShellBuild#201. + # + # Matched only where the command begins a line, so that a comment mentioning + # Export-ModuleMember -- including one explaining why the loader deliberately does not + # call it -- is not reported as a call. [^\S\r\n]* is horizontal whitespace only, so + # the match cannot start on a previous line. + if ($psm1Contents -match '(?m)^[^\S\r\n]*Export-ModuleMember\b') { + $sourceRootModule = [IO.Path]::Combine($Path, "$ModuleName.psm1") + Write-Warning ( + $LocalizedData.ExportModuleMemberInSourceRootModule -f $sourceRootModule + ) + } + '' | Out-File -FilePath $rootModule -Encoding 'utf8' if ($CompileHeader) { diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index 4d141d6..03847aa 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -42,4 +42,5 @@ CertificateExpired=The resolved certificate has expired (NotAfter: {0}). Code si CertificateMissingCodeSigningEku=The resolved certificate does not have the Code Signing Enhanced Key Usage (EKU: 1.3.6.1.5.5.7.3.3). Subject=[{0}] CertificateSourceStoreNotSupported=CertificateSource 'Store' is only supported on Windows platforms. CertificateValidationRelaxed=No unexpired code signing certificate was found, and validation was skipped, so an expired certificate was selected (NotAfter: {0}). Subject=[{1}] +ExportModuleMemberInSourceRootModule=The source root module [{0}] calls Export-ModuleMember. Compiling appends that call after the concatenated function files, where it runs against an output directory that no longer has the function directories to discover, and the effective export set is the intersection of that call and FunctionsToExport in the manifest. A loader that finds nothing therefore leaves the built module exporting nothing. Guard the call so it does nothing when the function directories are absent, or remove it and let the manifest govern the export set. '@ diff --git a/tests/Build-PSBuildModule.tests.ps1 b/tests/Build-PSBuildModule.tests.ps1 new file mode 100644 index 0000000..2891bfd --- /dev/null +++ b/tests/Build-PSBuildModule.tests.ps1 @@ -0,0 +1,579 @@ +# spell-checker:ignore excludeme psm1 psd1 + +# Dedicated coverage for Build-PSBuildModule (psake/PowerShellBuild#98). +# +# build.tests.ps1 reaches this same function through a real psake build of tests/TestModule, but +# every assertion there reads the text of the file that was written. This file calls +# Build-PSBuildModule directly, so each parameter can be varied on its own, and it imports what +# the build produced so the assertions are about the module a consumer would install rather than +# about the bytes on disk. +# +# Two source root modules are used, and the difference between them is the whole of +# psake/PowerShellBuild#201: +# +# * The guarded loader does nothing when Public/ and Private/ are absent, and never calls +# Export-ModuleMember. It survives compilation, so a module built from it exports its public +# functions in either mode. +# * The naive scaffold loader -- the shape Plaster and most module templates generate -- calls +# Export-ModuleMember with the names it discovered. Compile mode appends the source .psm1 +# after the concatenated functions and copies no Public/ directory to the output, so that +# call becomes Export-ModuleMember -Function @() and the built module exports nothing even +# though the manifest names every public function. The build still succeeds; the warning +# added for #201 is the only thing that says so. + +Describe 'Build-PSBuildModule' { + + BeforeAll { + $script:repositoryRoot = Split-Path -Path $PSScriptRoot -Parent + Import-Module -Name ([IO.Path]::Combine($script:repositoryRoot, 'Output', 'PowerShellBuild')) -Force + Import-Module -Name ([IO.Path]::Combine($PSScriptRoot, 'fixtures', 'FixtureHelpers.psm1')) -Force + + # Distinctive markers so an assertion can locate them in the compiled root module without + # colliding with anything the fixture functions contain. + $script:compileHeader = '# ===== module header =====' + $script:compileFooter = '# ===== module footer =====' + $script:compileScriptHeader = '# ----- begin script -----' + $script:compileScriptFooter = '# ----- end script -----' + $script:loaderMarker = '# PSBuildTestFixture root module loader' + + # Single-quoted here-strings: the loader text has to reach the fixture with $PSScriptRoot + # intact rather than expanded against this test file. + $script:guardedLoader = @' +# PSBuildTestFixture root module loader +# +# Guarded for compiled builds: compile mode concatenates the function files into this .psm1 and +# copies neither Public/ nor Private/ to the output, so the loader must do nothing when those +# directories are absent. There is deliberately no Export-ModuleMember call -- the built +# manifest's FunctionsToExport is what governs the export set. +foreach ($sourceDirectoryName in @('Public', 'Private')) { + $sourceDirectoryPath = Join-Path -Path $PSScriptRoot -ChildPath $sourceDirectoryName + if (-not (Test-Path -Path $sourceDirectoryPath)) { + continue + } + + $sourceFile = Get-ChildItem -Path (Join-Path -Path $sourceDirectoryPath -ChildPath '*.ps1') + foreach ($import in $sourceFile) { + . $import.FullName + } +} +'@ + + $script:naiveLoader = @' +# PSBuildTestFixture root module loader +# +# The standard scaffold loader, reproduced from psake/PowerShellBuild#201. It discovers the +# function files at import time and exports whatever it found. +$public = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public/*.ps1') -ErrorAction SilentlyContinue) +$private = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Private/*.ps1') -ErrorAction SilentlyContinue) +foreach ($import in $public + $private) { + . $import.FullName +} + +Export-ModuleMember -Function $public.BaseName +'@ + + function New-PSBuildModuleScenario { + <# + .SYNOPSIS + Create an isolated source module and the output path to build it into. + .DESCRIPTION + Copies the shared PSBuildTestFixture module into its own directory, replaces its + root module with the requested loader shape, and returns the paths the tests + assert against. The fixture is copied rather than used in place so that no test + run mutates the checked-in fixture. + .PARAMETER Path + Directory to create the scenario under, typically $TestDrive. Passed in rather + than read from the caller so the helper does not depend on a Pester construct. + .PARAMETER Name + Name of the scenario directory. Give each scenario its own name so scenarios in + the same run cannot observe each other's output. + .PARAMETER Loader + Which root module loader to stamp onto the copy. 'Guarded' survives compilation; + 'Naive' is the scaffold loader that psake/PowerShellBuild#201 is about. + .EXAMPLE + PS> $scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'compiled' + + Creates $TestDrive/compiled/PSBuildTestFixture with the guarded loader. + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Path, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Name, + + [ValidateSet('Guarded', 'Naive')] + [string] + $Loader = 'Guarded' + ) + + $scenarioRoot = Join-Path -Path $Path -ChildPath $Name + $sourcePath = Copy-PSBuildTestFixture -Destination $scenarioRoot + + $loaderText = if ($Loader -eq 'Naive') { $script:naiveLoader } else { $script:guardedLoader } + Set-Content -Path (Join-Path -Path $sourcePath -ChildPath 'PSBuildTestFixture.psm1') -Value $loaderText + + # A directory the source tree carries that is not a compile directory, so + # CopyDirectories has something to copy. + $resourcePath = Join-Path -Path $sourcePath -ChildPath 'resources' + New-Item -Path $resourcePath -ItemType Directory -Force > $null + Set-Content -Path (Join-Path -Path $resourcePath -ChildPath 'widget-template.json') -Value '{}' + + $destinationPath = Join-Path -Path $scenarioRoot -ChildPath 'Output' + + [PSCustomObject]@{ + ModuleName = 'PSBuildTestFixture' + SourcePath = $sourcePath + DestinationPath = $destinationPath + ManifestPath = Join-Path -Path $destinationPath -ChildPath 'PSBuildTestFixture.psd1' + RootModulePath = Join-Path -Path $destinationPath -ChildPath 'PSBuildTestFixture.psm1' + } + } + + function Get-BuiltModuleExportedFunctionName { + <# + .SYNOPSIS + Report the function names a built module actually exports. + .DESCRIPTION + Imports the built module through its manifest and returns the exported function + names, then removes it again. Reading the export set from the loaded module is + the point: the manifest's FunctionsToExport and the root module's + Export-ModuleMember are intersected, so only an import shows which commands a + consumer would get. + + The import and removal are paired inside one call so the suite never carries a + built fixture module in session state, and so each scenario reports its own + exports even though every scenario builds a module of the same name. + .PARAMETER ManifestPath + Path to the built module's manifest. + .EXAMPLE + PS> Get-BuiltModuleExportedFunctionName -ManifestPath $scenario.ManifestPath + + Returns the sorted names of the functions the built module exports. + .OUTPUTS + System.String[] + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ManifestPath + ) + + $module = Import-Module -Name $ManifestPath -Force -PassThru + $exportedFunctionName = @($module.ExportedFunctions.Keys | Sort-Object) + Remove-Module -ModuleInfo $module -Force -ErrorAction SilentlyContinue + + # Comma-wrapped so a module that exports nothing comes back as an empty array rather + # than as no output at all, which is the distinction these tests are about. + , $exportedFunctionName + } + } + + AfterAll { + Remove-Module -Name 'FixtureHelpers' -Force -ErrorAction SilentlyContinue + } + + Context 'Building without compilation' { + + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'dot-sourced' + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Exclude = @('excludeme') + } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + } + + It 'Creates the destination directory' { + $script:scenario.DestinationPath | Should -Exist + } + + It 'Copies the manifest and the root module' { + $script:scenario.ManifestPath | Should -Exist + $script:scenario.RootModulePath | Should -Exist + } + + It 'Preserves the <_> directory' -ForEach @('Public', 'Private', 'resources') { + Join-Path -Path $script:scenario.DestinationPath -ChildPath $_ | Should -Exist + } + + It 'Removes files matching an Exclude pattern' { + Join-Path -Path $script:scenario.DestinationPath -ChildPath 'excludeme.txt' | Should -Not -Exist + } + + It 'Writes the public function names into FunctionsToExport' { + $manifest = Import-PowerShellDataFile -Path $script:scenario.ManifestPath + + @($manifest.FunctionsToExport | Sort-Object) | Should -Be @('Get-Widget', 'Set-Widget') + } + + It 'Builds a module that exports its public functions' { + $exportedFunctionName = Get-BuiltModuleExportedFunctionName -ManifestPath $script:scenario.ManifestPath + + $exportedFunctionName | Should -Be @('Get-Widget', 'Set-Widget') + } + + It 'Does not export the private helper' { + $exportedFunctionName = Get-BuiltModuleExportedFunctionName -ManifestPath $script:scenario.ManifestPath + + $exportedFunctionName | Should -Not -Contain 'Test-WidgetName' + } + + It 'Emits no warning' { + $script:buildWarning | Should -BeNullOrEmpty + } + } + + Context 'Building with compilation' { + + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'compiled' + + # A script file the Exclude pattern has to keep out of the concatenated root module. + # Added here rather than in the fixture because compile mode is the only place a + # .ps1 exclusion is applied to the compile directories. + $excludedScriptPath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'Private/excludeme.ps1' + Set-Content -Path $excludedScriptPath -Value 'function Get-ExcludedWidget { }' + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + CompileDirectories = @('Public', 'Private') + CopyDirectories = @('resources') + CompileHeader = $script:compileHeader + CompileFooter = $script:compileFooter + CompileScriptHeader = $script:compileScriptHeader + CompileScriptFooter = $script:compileScriptFooter + Exclude = @('excludeme') + } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + + $script:rootModuleContent = Get-Content -Path $script:scenario.RootModulePath -Raw + } + + It 'Produces only the manifest and a monolithic root module' { + @(Get-ChildItem -Path $script:scenario.DestinationPath -File).Count | Should -Be 2 + $script:scenario.ManifestPath | Should -Exist + $script:scenario.RootModulePath | Should -Exist + } + + It 'Does not copy the <_> compile directory to the output' -ForEach @('Public', 'Private') { + Join-Path -Path $script:scenario.DestinationPath -ChildPath $_ | Should -Not -Exist + } + + It 'Copies a CopyDirectories directory as-is' { + [IO.Path]::Combine( + $script:scenario.DestinationPath, 'resources', 'widget-template.json' + ) | Should -Exist + } + + It 'Concatenates <_> into the root module' -ForEach @('Get-Widget', 'Set-Widget', 'Test-WidgetName') { + $script:rootModuleContent | Should -BeLike "*function $_*" + } + + It 'Leaves out a script matching an Exclude pattern' { + $script:rootModuleContent | Should -Not -BeLike '*Get-ExcludedWidget*' + } + + It 'Places the compile header above the concatenated functions' { + $headerIndex = $script:rootModuleContent.IndexOf($script:compileHeader) + $firstFunctionIndex = $script:rootModuleContent.IndexOf('function ') + + $headerIndex | Should -BeGreaterOrEqual 0 + $headerIndex | Should -BeLessThan $firstFunctionIndex + } + + It 'Places the compile footer at the end' { + $footerIndex = $script:rootModuleContent.IndexOf($script:compileFooter) + $loaderIndex = $script:rootModuleContent.IndexOf($script:loaderMarker) + + $footerIndex | Should -BeGreaterOrEqual 0 + $footerIndex | Should -BeGreaterThan $loaderIndex + } + + It 'Wraps every compiled script in the script header and footer' { + # Three scripts survive the Exclude pattern: two public and one private. + $headerMatch = [regex]::Matches($script:rootModuleContent, [regex]::Escape($script:compileScriptHeader)) + $footerMatch = [regex]::Matches($script:rootModuleContent, [regex]::Escape($script:compileScriptFooter)) + + $headerMatch.Count | Should -Be 3 + $footerMatch.Count | Should -Be 3 + } + + It 'Appends the source root module after the concatenated functions' { + # Compared against the last script footer rather than the last 'function ' keyword: + # the loader's own comments talk about function files, so a keyword search would find + # text inside the appended loader itself. + $loaderIndex = $script:rootModuleContent.IndexOf($script:loaderMarker) + $lastScriptFooterIndex = $script:rootModuleContent.LastIndexOf($script:compileScriptFooter) + + $loaderIndex | Should -BeGreaterThan $lastScriptFooterIndex + } + + It 'Writes the public function names into FunctionsToExport' { + $manifest = Import-PowerShellDataFile -Path $script:scenario.ManifestPath + + @($manifest.FunctionsToExport | Sort-Object) | Should -Be @('Get-Widget', 'Set-Widget') + } + + It 'Builds a module that exports its public functions' { + # The assertion the suite was missing. Every other compile-mode assertion reads file + # text, and file text cannot tell the difference between a module that exports its + # commands and one that exports nothing (psake/PowerShellBuild#201). + $exportedFunctionName = Get-BuiltModuleExportedFunctionName -ManifestPath $script:scenario.ManifestPath + + $exportedFunctionName | Should -Be @('Get-Widget', 'Set-Widget') + } + + It 'Does not export the private helper' { + $exportedFunctionName = Get-BuiltModuleExportedFunctionName -ManifestPath $script:scenario.ManifestPath + + $exportedFunctionName | Should -Not -Contain 'Test-WidgetName' + } + + It 'Emits no warning for a root module that does not call Export-ModuleMember' { + # The guarded loader's comments name Export-ModuleMember while explaining why it + # deliberately does not call it, so this also pins that a mention in a comment is + # not reported as a call. + $script:buildWarning | Should -BeNullOrEmpty + } + } + + Context 'Selecting which directories are compiled' { + + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'public-only' + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + CompileDirectories = @('Public') + } + Build-PSBuildModule @buildParameter + + $script:rootModuleContent = Get-Content -Path $script:scenario.RootModulePath -Raw + } + + It 'Compiles the named directory' { + $script:rootModuleContent | Should -BeLike '*function Get-Widget*' + } + + It 'Leaves out a directory that was not named' { + $script:rootModuleContent | Should -Not -BeLike '*function Test-WidgetName*' + } + } + + Context 'Building with compilation from a scaffold loader' { + + # psake/PowerShellBuild#201. The naive loader is the shape almost every module template + # generates, and compiling it produces a module that exports nothing while the build + # reports success. The fix for #201 is the warning asserted below, not a change to what + # gets built, so the "exports nothing" assertion here pins current, known behavior + # rather than desired behavior. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'compiled-scaffold' -Loader 'Naive' + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + CompileDirectories = @('Public', 'Private') + } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + } + + It 'Warns that the source root module calls Export-ModuleMember' { + $script:buildWarning -join [Environment]::NewLine | Should -BeLike '*Export-ModuleMember*' + } + + It 'Names the source root module in the warning' { + $sourceRootModulePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'PSBuildTestFixture.psm1' + + $script:buildWarning -join [Environment]::NewLine | Should -BeLike "*$sourceRootModulePath*" + } + + It 'Still writes the public function names into FunctionsToExport' { + # The manifest is correct. The effective export set is the intersection of + # FunctionsToExport and Export-ModuleMember, and the appended loader is what empties + # it, which is why a file-text assertion cannot see the failure. + $manifest = Import-PowerShellDataFile -Path $script:scenario.ManifestPath + + @($manifest.FunctionsToExport | Sort-Object) | Should -Be @('Get-Widget', 'Set-Widget') + } + + It 'Builds a module that exports nothing' { + # Current documented behavior, not desired behavior. The appended loader runs + # Export-ModuleMember -Function @() because compile mode copied no Public/ directory + # to the output. If a later change makes compiled scaffold modules export their + # functions, this test is the one to update. + $exportedFunctionName = Get-BuiltModuleExportedFunctionName -ManifestPath $script:scenario.ManifestPath + + $exportedFunctionName | Should -BeNullOrEmpty + } + } + + Context 'Building without compilation from a scaffold loader' { + + # The counterpart to the context above: the same source module, built without -Compile, + # keeps its Public/ directory, so the loader finds the files and the module exports its + # functions. Nothing is wrong with the loader; compilation is what breaks it, and the + # warning belongs to compile mode alone. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'dot-sourced-scaffold' -Loader 'Naive' + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + } + + It 'Builds a module that exports its public functions' { + $exportedFunctionName = Get-BuiltModuleExportedFunctionName -ManifestPath $script:scenario.ManifestPath + + $exportedFunctionName | Should -Be @('Get-Widget', 'Set-Widget') + } + + It 'Emits no warning' { + $script:buildWarning | Should -BeNullOrEmpty + } + } + + Context 'Converting the readme into about help' { + + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help' + $script:readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' + $script:readMeContent = '# PSBuildTestFixture readme content' + Set-Content -Path $script:readMePath -Value $script:readMeContent + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + ReadMePath = $script:readMePath + Culture = 'en-US' + } + Build-PSBuildModule @buildParameter + + $script:aboutHelpPath = [IO.Path]::Combine( + $script:scenario.DestinationPath, 'en-US', 'about_PSBuildTestFixture.help.txt' + ) + } + + It 'Writes the readme as the about help file in the culture directory' { + $script:aboutHelpPath | Should -Exist + } + + It 'Writes the readme content unchanged' { + (Get-Content -Path $script:aboutHelpPath -Raw).Trim() | Should -Be $script:readMeContent + } + } + + Context 'Choosing the about help culture' { + + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help-culture' + $readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' + Set-Content -Path $readMePath -Value '# PSBuildTestFixture readme content' + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + ReadMePath = $readMePath + Culture = 'fr-FR' + } + Build-PSBuildModule @buildParameter + } + + It 'Writes the about help file into the requested culture directory' { + [IO.Path]::Combine( + $script:scenario.DestinationPath, 'fr-FR', 'about_PSBuildTestFixture.help.txt' + ) | Should -Exist + } + } + + Context 'Converting the readme when the culture directory already exists' { + + # Pins current behavior, which looks wrong: the Copy-Item that writes the about help file + # sits inside the `if (-not (Test-Path $culturePath))` branch that creates the culture + # directory, so a build whose output already has that directory silently writes no about + # help file at all. It is reachable in compile mode whenever CopyDirectories names the + # culture directory. Left as-is here because it is a behavior change outside the scope of + # psake/PowerShellBuild#98 and #201; reported separately. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help-existing' + $readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' + Set-Content -Path $readMePath -Value '# PSBuildTestFixture readme content' + + $culturePath = Join-Path -Path $script:scenario.DestinationPath -ChildPath 'en-US' + New-Item -Path $culturePath -ItemType Directory -Force > $null + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + ReadMePath = $readMePath + Culture = 'en-US' + } + Build-PSBuildModule @buildParameter + } + + It 'Writes no about help file' { + [IO.Path]::Combine( + $script:scenario.DestinationPath, 'en-US', 'about_PSBuildTestFixture.help.txt' + ) | Should -Not -Exist + } + } + + Context 'Building a source module with no public functions' { + + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'no-public' + Remove-Item -Path (Join-Path -Path $script:scenario.SourcePath -ChildPath 'Public') -Recurse -Force + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + } + Build-PSBuildModule @buildParameter + } + + It 'Leaves FunctionsToExport in the manifest alone' { + # Nothing was discovered to write, so the manifest keeps whatever the source declared + # rather than being emptied. + $manifest = Import-PowerShellDataFile -Path $script:scenario.ManifestPath + + @($manifest.FunctionsToExport | Sort-Object) | Should -Be @('Get-Widget', 'Set-Widget') + } + } +} diff --git a/tests/TestModule/TestModule/TestModule.psm1 b/tests/TestModule/TestModule/TestModule.psm1 index 9d39ea3..9a87b20 100644 --- a/tests/TestModule/TestModule/TestModule.psm1 +++ b/tests/TestModule/TestModule/TestModule.psm1 @@ -1 +1,22 @@ -# I'm some code in the src PSM1 +# TestModule root module loader +# +# Dot-sources the function files at import time, guarded so the same file also works after +# PowerShellBuild compiles the module: compile mode concatenates the function files into this +# .psm1 and copies neither Public/ nor Private/ to the output, so the loader has to do nothing +# when those directories are absent. +# +# There is deliberately no Export-ModuleMember call. PowerShellBuild writes the public function +# names into FunctionsToExport in the built manifest, and a call here would run after the +# concatenated functions in a compiled build, where it would find no files and empty the export +# set the manifest had just been given. See psake/PowerShellBuild#201. +foreach ($sourceDirectoryName in @('Public', 'Private')) { + $sourceDirectoryPath = [IO.Path]::Combine($PSScriptRoot, $sourceDirectoryName) + if (-not (Test-Path -Path $sourceDirectoryPath)) { + continue + } + + $sourceFile = Get-ChildItem -Path ([IO.Path]::Combine($sourceDirectoryPath, '*.ps1')) + foreach ($import in $sourceFile) { + . $import.FullName + } +} diff --git a/tests/build.tests.ps1 b/tests/build.tests.ps1 index d86d593..e5cc2da 100644 --- a/tests/build.tests.ps1 +++ b/tests/build.tests.ps1 @@ -34,6 +34,15 @@ Describe 'Build' { $global:PSBuildCompile = $true ./build.ps1 -Task Build } | Wait-Job + + # Read the export set from the loaded module, not from the file. A module's + # effective exports are the intersection of the manifest's FunctionsToExport and + # whatever the root module exports, so a compiled module can carry every function + # in its text and still export none of them (psake/PowerShellBuild#201). Imported + # once here and removed again so no built fixture module is left in session state. + $builtModule = Import-Module -Name "$script:testModuleOutputPath/TestModule.psd1" -Force -PassThru + $script:exportedFunctionName = @($builtModule.ExportedFunctions.Keys | Sort-Object) + Remove-Module -ModuleInfo $builtModule -Force -ErrorAction SilentlyContinue } AfterAll { @@ -73,6 +82,19 @@ Describe 'Build' { "$script:testModuleOutputPath/TestModule.psm1" | Should -Not -FileContentMatch '=== EXCLUDE ME ===' } + It 'Appends the source PSM1 after the compiled functions' { + "$script:testModuleOutputPath/TestModule.psm1" | + Should -FileContentMatch '# TestModule root module loader' + } + + It 'Exports its public function' { + $script:exportedFunctionName | Should -Be @('Get-HelloWorld') + } + + It 'Does not export its private function' { + $script:exportedFunctionName | Should -Not -Contain 'GetHelloWorld' + } + It 'Has MAML help XML' { "$script:testModuleOutputPath/en-US/TestModule-help.xml" | Should -Exist } @@ -94,6 +116,12 @@ Describe 'Build' { Write-Debug "TestModule output path: $script:testModuleOutputPath" $items = Get-ChildItem -Path $script:testModuleOutputPath -Recurse -File Write-Debug ($items | Format-Table FullName | Out-String) + + # Same reasoning as the compiled context: staging the files is not the same as + # producing a module that exports anything, and only an import can tell them apart. + $builtModule = Import-Module -Name "$script:testModuleOutputPath/TestModule.psd1" -Force -PassThru + $script:exportedFunctionName = @($builtModule.ExportedFunctions.Keys | Sort-Object) + Remove-Module -ModuleInfo $builtModule -Force -ErrorAction SilentlyContinue } AfterAll { @@ -117,6 +145,14 @@ Describe 'Build' { (Get-ChildItem -Path $script:testModuleOutputPath -File -Filter '*excludeme*' -Recurse).Count | Should -Be 0 } + It 'Exports its public function' { + $script:exportedFunctionName | Should -Be @('Get-HelloWorld') + } + + It 'Does not export its private function' { + $script:exportedFunctionName | Should -Not -Contain 'GetHelloWorld' + } + It 'Has MAML help XML' { "$script:testModuleOutputPath/en-US/TestModule-help.xml" | Should -Exist } From a00fd6c43e77461807498826d21feee69915f444 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 11:50:57 -0400 Subject: [PATCH 2/5] fix: Stop the Exclude filter from aborting the loop it is filtering 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 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- CHANGELOG.md | 10 ++++++++ .../Private/Remove-ExcludedItem.ps1 | 22 +++++++++++++---- tests/Build-PSBuildModule.tests.ps1 | 24 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d94c4..ad8f122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,16 @@ Everything below is the detail, one entry per issue. the export set. A mention of `Export-ModuleMember` in a comment does not trigger the warning. +- [**#98**](https://github.com/psake/PowerShellBuild/issues/98) + `$PSBPreference.Build.Exclude` no longer aborts the build it is filtering. + The exclusion used 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; on Windows PowerShell 5.1 the `break` escaped the function entirely and + aborted whatever loop the caller was running, silently and with no error. + Found while adding the coverage for `Build-PSBuildModule` that #98 asked + for, which reaches the filter directly rather than one item at a time + through the pipeline. + - [**#193**](https://github.com/psake/PowerShellBuild/issues/193) `$PSBPreference.Sign.SkipCertificateValidation` now does something. It was read only by `psakeFile.ps1`, and even there `Get-PSBuildCertificate` consulted diff --git a/PowerShellBuild/Private/Remove-ExcludedItem.ps1 b/PowerShellBuild/Private/Remove-ExcludedItem.ps1 index 9cad7f6..81cc133 100644 --- a/PowerShellBuild/Private/Remove-ExcludedItem.ps1 +++ b/PowerShellBuild/Private/Remove-ExcludedItem.ps1 @@ -16,14 +16,28 @@ function Remove-ExcludedItem { } process { - :item foreach ($item in $InputObject) { + # A labeled break used to skip an excluded item here. On Windows PowerShell 5.1 that + # break escapes this function instead of ending the loop it names, and an escaping + # break aborts whatever loop the caller happened to be running, silently and with no + # error. Pester reports it as "a 'break' or 'continue' statement with a label that + # does not match any enclosing loop escaped from your code". A flag keeps all flow + # control inside this function. + # + # The labeled break was also aimed at the wrong loop: it ended the loop over + # $InputObject rather than skipping the one excluded item, so a caller that passed a + # collection through -InputObject lost every item after the first excluded one. + $isExcluded = $false foreach ($regex in $Exclude) { - if ($_ -match $regex) { - break item + if ($item -match $regex) { + $isExcluded = $true + break } } - $keepers.Add($_) + + if (-not $isExcluded) { + $keepers.Add($item) + } } } diff --git a/tests/Build-PSBuildModule.tests.ps1 b/tests/Build-PSBuildModule.tests.ps1 index 2891bfd..27d5702 100644 --- a/tests/Build-PSBuildModule.tests.ps1 +++ b/tests/Build-PSBuildModule.tests.ps1 @@ -383,6 +383,30 @@ Export-ModuleMember -Function $public.BaseName } } + Context 'Filtering the compiled scripts' { + + # Reaches the private Remove-ExcludedItem directly. The compile path pipes items into it + # one at a time, which hides what a collection exposes: the exclusion used a labeled + # break aimed at the loop over the whole input, so every item after the first excluded + # one was dropped. On Windows PowerShell 5.1 that break escaped the function outright + # and aborted the caller's loop. + It 'Keeps the items that follow an excluded one' { + InModuleScope -ModuleName 'PowerShellBuild' -ScriptBlock { + # The files do not have to exist: the exclusion is a regular expression match + # against the path, and only the names are read back. + $item = @( + [IO.FileInfo]::new('source/Public/Get-Widget.ps1') + [IO.FileInfo]::new('source/Private/excludeme.ps1') + [IO.FileInfo]::new('source/Private/Test-WidgetName.ps1') + ) + + $keptItem = Remove-ExcludedItem -InputObject $item -Exclude @('excludeme') + + @($keptItem.Name) | Should -Be @('Get-Widget.ps1', 'Test-WidgetName.ps1') + } + } + } + Context 'Building with compilation from a scaffold loader' { # psake/PowerShellBuild#201. The naive loader is the shape almost every module template From b103c4db6c64b9c8cc21fb7c8856a57fba36f32a Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 12:02:43 -0400 Subject: [PATCH 3/5] fix: Read compiled function files through their full path 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 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- CHANGELOG.md | 14 ++++++ .../Public/Build-PSBuildModule.ps1 | 15 +++++-- tests/Build-PSBuildModule.tests.ps1 | 44 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad8f122..ec1f65c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,20 @@ Everything below is the detail, one entry per issue. for, which reaches the filter directly rather than one item at a time through the pipeline. +- [**#98**](https://github.com/psake/PowerShellBuild/issues/98) + `$PSBPreference.Build.CompileModule = $true` no longer produces a module + with no functions in it when the build runs from a different drive than the + module source. The compile loop read each function file through a path + resolved relative to the current location, and a current location cannot + always express another path relatively: on Windows PowerShell 5.1 a source + on another drive resolves to `.\C:\...`, and a source outside the current + PSDrive's root to a `..\` path that cannot climb past that root. Neither + reads back, so every function was silently dropped and the compiled `.psm1` + came out holding only its headers and footers -- and the build reported + success. Files are now read through their full path. Also found while + adding the #98 coverage; this repository's own Windows PowerShell 5.1 CI + job reproduces it, with the checkout on `D:` and the test drive on `C:`. + - [**#193**](https://github.com/psake/PowerShellBuild/issues/193) `$PSBPreference.Sign.SkipCertificateValidation` now does something. It was read only by `psakeFile.ps1`, and even there `Get-PSBuildCertificate` consulted diff --git a/PowerShellBuild/Public/Build-PSBuildModule.ps1 b/PowerShellBuild/Public/Build-PSBuildModule.ps1 index 42c542f..2fa402a 100644 --- a/PowerShellBuild/Public/Build-PSBuildModule.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildModule.ps1 @@ -178,14 +178,23 @@ function Build-PSBuildModule { Encoding = 'utf8' } $allScripts | ForEach-Object { - $srcFile = Resolve-Path $_.FullName -Relative - Write-Verbose ($LocalizedData.AddingFileToPsm1 -f $srcFile) + # Read through the full path. This used to read through a path resolved relative to + # the current location, which is not something every current location can express. + # On Windows PowerShell 5.1 a source file on another drive resolves to a path like + # .\C:\src\Public\Get-Widget.ps1, and a source outside the current PSDrive's root to + # a ..\ path that cannot climb past that root. Neither can be read back, so + # Get-Content silently returned nothing for every file and the compiled .psm1 came + # out holding its headers and footers and none of the functions -- while the build + # reported success. It is reachable whenever the build runs from a different drive + # than the module source, which is how the Windows PowerShell 5.1 CI job is laid out. + $sourceFilePath = $_.FullName + Write-Verbose ($LocalizedData.AddingFileToPsm1 -f $sourceFilePath) if ($CompileScriptHeader) { Write-Output $CompileScriptHeader } - Get-Content $srcFile + Get-Content -Path $sourceFilePath if ($CompileScriptFooter) { Write-Output $CompileScriptFooter diff --git a/tests/Build-PSBuildModule.tests.ps1 b/tests/Build-PSBuildModule.tests.ps1 index 27d5702..d385a21 100644 --- a/tests/Build-PSBuildModule.tests.ps1 +++ b/tests/Build-PSBuildModule.tests.ps1 @@ -383,6 +383,50 @@ Export-ModuleMember -Function $public.BaseName } } + Context 'Compiling from a current location that cannot express the source path' { + + # The compile loop used to read each function file through a path resolved relative to + # the current location. A current location cannot always express another path + # relatively: on Windows PowerShell 5.1 a source on another drive resolves to .\C:\..., + # and a source outside the current PSDrive's root to a ..\ path that cannot climb past + # that root. Both read back as nothing, and the compiled module came out with its + # headers and footers and none of its functions while the build reported success. + # + # The PSDrive here reproduces that on Windows PowerShell 5.1, where it is a real defect; + # PowerShell 7 returns the absolute path in the same situation, so there the test simply + # confirms the behavior. CI's Windows PowerShell 5.1 job hits the drive-letter form of + # this for real, with the repository on D: and the test drive on C:. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'relative-path' + + $isolatedRootPath = Join-Path -Path $TestDrive -ChildPath 'relative-path-location' + New-Item -Path $isolatedRootPath -ItemType Directory -Force > $null + New-PSDrive -Name 'PSBuildTestLocation' -PSProvider 'FileSystem' -Root $isolatedRootPath > $null + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + CompileDirectories = @('Public', 'Private') + } + + Push-Location -Path 'PSBuildTestLocation:\' + try { + Build-PSBuildModule @buildParameter + } finally { + Pop-Location + Remove-PSDrive -Name 'PSBuildTestLocation' -Force + } + + $script:rootModuleContent = Get-Content -Path $script:scenario.RootModulePath -Raw + } + + It 'Compiles <_> into the root module' -ForEach @('Get-Widget', 'Set-Widget', 'Test-WidgetName') { + $script:rootModuleContent | Should -BeLike "*function $_*" + } + } + Context 'Filtering the compiled scripts' { # Reaches the private Remove-ExcludedItem directly. The compile path pipes items into it From d8b0aba83d5f171182c9064e46f8e004dd0e7d10 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 13:07:16 -0400 Subject: [PATCH 4/5] docs: List the new test file in the repository instructions 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 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- instructions/repository-specific.instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/instructions/repository-specific.instructions.md b/instructions/repository-specific.instructions.md index 683a1d1..b313842 100644 --- a/instructions/repository-specific.instructions.md +++ b/instructions/repository-specific.instructions.md @@ -255,6 +255,7 @@ own floor is **Pester 6.0.0** as of psake/PowerShellBuild#172, matching what CI | ---------------------------------------- | -------------------------------------------------------------------- | | `build.tests.ps1` | Module compilation, file staging, exclusion, header/footer injection | | `Build-PSBuildHelp.tests.ps1` | Markdown and MAML help generation (skipped without PlatyPS) | +| `Build-PSBuildModule.tests.ps1` | The Build-PSBuildModule function directly, compiled and not | | `Clear-PSBuildOutputFolder.tests.ps1` | Output directory removal | | `Fixtures.tests.ps1` | The shared test fixture helpers themselves | | `Get-PSBuildCertificate.tests.ps1` | Signing certificate resolution | From 9874e97820bfde55959818cd3ca73174ae6d7a31 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 13:56:38 -0400 Subject: [PATCH 5/5] fix: Detect the Export-ModuleMember call by parsing, and read by literal 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 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- .../Public/Build-PSBuildModule.ps1 | 31 ++++-- tests/Build-PSBuildModule.tests.ps1 | 96 ++++++++++++++++++- 2 files changed, 117 insertions(+), 10 deletions(-) diff --git a/PowerShellBuild/Public/Build-PSBuildModule.ps1 b/PowerShellBuild/Public/Build-PSBuildModule.ps1 index 2fa402a..ae41fe9 100644 --- a/PowerShellBuild/Public/Build-PSBuildModule.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildModule.ps1 @@ -1,4 +1,4 @@ -# spell-checker:ignore modulename +# spell-checker:ignore modulename function Build-PSBuildModule { <# .SYNOPSIS @@ -142,11 +142,23 @@ function Build-PSBuildModule { # rewritten: what the consumer's root module should do instead depends on the module. # See psake/PowerShellBuild#201. # - # Matched only where the command begins a line, so that a comment mentioning - # Export-ModuleMember -- including one explaining why the loader deliberately does not - # call it -- is not reported as a call. [^\S\r\n]* is horizontal whitespace only, so - # the match cannot start on a previous line. - if ($psm1Contents -match '(?m)^[^\S\r\n]*Export-ModuleMember\b') { + # Found by parsing rather than by matching text, so that the command named in a + # comment or quoted in a here-string -- including a comment explaining why the loader + # deliberately does not call it -- is not reported as a call. A line-anchored regex + # gets the single-line # comment right and still fires inside a <# #> block or a + # here-string, because it cannot see that the line it anchored on is not code. + $rootModuleAst = [System.Management.Automation.Language.Parser]::ParseInput( + $psm1Contents, [ref] $null, [ref] $null + ) + $exportModuleMemberCall = $rootModuleAst.FindAll( + { + param($node) + $node -is [System.Management.Automation.Language.CommandAst] -and + $node.GetCommandName() -eq 'Export-ModuleMember' + }, + $true + ) + if ($exportModuleMemberCall) { $sourceRootModule = [IO.Path]::Combine($Path, "$ModuleName.psm1") Write-Warning ( $LocalizedData.ExportModuleMemberInSourceRootModule -f $sourceRootModule @@ -194,7 +206,12 @@ function Build-PSBuildModule { Write-Output $CompileScriptHeader } - Get-Content -Path $sourceFilePath + # -LiteralPath, not -Path: the value is a FullName, and -Path reads [ ] * ? as + # wildcards. A source file named Get-Widget[1].ps1, or any checkout under a + # directory like repo[main], would then match nothing and be dropped from the + # compiled module in silence -- the same failure this full-path read exists to + # prevent, reached a different way. + Get-Content -LiteralPath $sourceFilePath if ($CompileScriptFooter) { Write-Output $CompileScriptFooter diff --git a/tests/Build-PSBuildModule.tests.ps1 b/tests/Build-PSBuildModule.tests.ps1 index d385a21..3ed1150 100644 --- a/tests/Build-PSBuildModule.tests.ps1 +++ b/tests/Build-PSBuildModule.tests.ps1 @@ -1,4 +1,4 @@ -# spell-checker:ignore excludeme psm1 psd1 +# spell-checker:ignore excludeme psm1 psd1 # Dedicated coverage for Build-PSBuildModule (psake/PowerShellBuild#98). # @@ -70,6 +70,30 @@ foreach ($import in $public + $private) { } Export-ModuleMember -Function $public.BaseName +'@ + + # Names Export-ModuleMember only inside a block comment. A line-anchored regex fires + # on it -- the anchor lands on a line it cannot tell is not code -- where parsing the + # file does not. This fixture is what separates the two approaches. + $script:documentedLoader = @' +# PSBuildTestFixture root module loader +<# + This module deliberately does not call the command named on the next line, which is + what the standard scaffold would have done here: + Export-ModuleMember -Function $public.BaseName + + The built manifest FunctionsToExport governs the export set instead. +#> +foreach ($sourceDirectoryName in @('Public', 'Private')) { + $sourceDirectoryPath = Join-Path -Path $PSScriptRoot -ChildPath $sourceDirectoryName + if (-not (Test-Path -Path $sourceDirectoryPath)) { + continue + } + + foreach ($import in (Get-ChildItem -Path (Join-Path -Path $sourceDirectoryPath -ChildPath '*.ps1'))) { + . $import.FullName + } +} '@ function New-PSBuildModuleScenario { @@ -110,7 +134,7 @@ Export-ModuleMember -Function $public.BaseName [string] $Name, - [ValidateSet('Guarded', 'Naive')] + [ValidateSet('Guarded', 'Naive', 'Documented')] [string] $Loader = 'Guarded' ) @@ -118,7 +142,11 @@ Export-ModuleMember -Function $public.BaseName $scenarioRoot = Join-Path -Path $Path -ChildPath $Name $sourcePath = Copy-PSBuildTestFixture -Destination $scenarioRoot - $loaderText = if ($Loader -eq 'Naive') { $script:naiveLoader } else { $script:guardedLoader } + $loaderText = switch ($Loader) { + 'Naive' { $script:naiveLoader } + 'Documented' { $script:documentedLoader } + default { $script:guardedLoader } + } Set-Content -Path (Join-Path -Path $sourcePath -ChildPath 'PSBuildTestFixture.psm1') -Value $loaderText # A directory the source tree carries that is not a compile directory, so @@ -531,6 +559,68 @@ Export-ModuleMember -Function $public.BaseName } } + Context 'Compiling a root module that only mentions Export-ModuleMember' { + + # The warning states a fact -- "calls Export-ModuleMember" -- and tells the consumer to + # guard or remove the call. Reported against a comment, it sends them looking for a + # call that does not exist. Detection therefore parses the root module instead of + # matching its text. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'documented' -Loader 'Documented' + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + CompileDirectories = @('Public', 'Private') + } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + } + + It 'Emits no warning for a mention inside a block comment' { + $script:buildWarning | Should -BeNullOrEmpty + } + + It 'Builds a module that exports its public functions' { + $exportedFunctionName = Get-BuiltModuleExportedFunctionName -ManifestPath $script:scenario.ManifestPath + + $exportedFunctionName | Should -Be @('Get-Widget', 'Set-Widget') + } + } + + Context 'Compiling a source file whose name contains wildcard characters' { + + # FullName is a literal path, 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 -- the same silent-drop outcome as the drive-relative + # path this loop already guards against, reached a different way. + # + # A bracketed directory would exercise it too, but not testably: New-ModuleManifest + # has no -LiteralPath, so the fixture cannot be created in one. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'wildcard-name' + $bracketedFilePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'Public/Get-Widget[legacy].ps1' + Set-Content -LiteralPath $bracketedFilePath -Value 'function Get-WidgetLegacy { 1 }' + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + CompileDirectories = @('Public', 'Private') + } + Build-PSBuildModule @buildParameter + } + + It 'Writes the function body into the compiled root module' { + $rootModuleContent = Get-Content -LiteralPath $script:scenario.RootModulePath -Raw + + $rootModuleContent | Should -Match 'function Get-WidgetLegacy' + } + } + Context 'Converting the readme into about help' { BeforeAll {