diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22647b2..510d0c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,6 @@ jobs: # Windows PowerShell 5.1 and PowerShell 7+ (Desktop and Core, Windows/Linux/macOS) # Edit freely - nothing regenerates it. Keep it in sync with the target profiles in # Tools/PSScriptAnalyzer.psd1 and CompatiblePSEditions in the module manifest. - # - # modulePath is the host's CurrentUser module folder, cached below so the slow - # first Install-Module (minutes under WinPS 5.1's inbox PowerShellGet) is paid - # only when Tools/install_dev_requirements.ps1 or module.psd1 changes. include: - { os: windows-latest, host: powershell, name: 'win / WinPS 5.1', modulePath: '~\Documents\WindowsPowerShell\Modules' } @@ -36,16 +32,12 @@ jobs: steps: - uses: actions/checkout@v7 - # On a cache hit the install script finds the modules via Get-Module -ListAvailable - # and skips Install-Module entirely. - name: Cache PowerShell modules uses: actions/cache@v6 with: path: ${{ matrix.modulePath }} key: psmodules-${{ matrix.os }}-${{ matrix.host }}-${{ hashFiles('Tools/install_dev_requirements.ps1', 'module.psd1') }} - # matrix.host selects the runtime: 'powershell' = Windows PowerShell 5.1, 'pwsh' = PowerShell 7+. - # The shell key cannot use the matrix context, so run under pwsh and invoke the target host. - name: Install dev requirements shell: pwsh run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task install_dev_requirements @@ -58,6 +50,10 @@ jobs: shell: pwsh run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task build - - name: Test + - name: Test (source tree) shell: pwsh - run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test + run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test -Target Source + + - name: Test (built module, artifact checks) + shell: pwsh + run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test -Target Dist -Path Tests/Module.Tests.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8e94c3..2b82421 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,6 @@ jobs: exit 1 fi - # This repository is a template until './tasks.ps1 prepare' has run. Publishing the - # unrenamed template to the Gallery would be a mistake, so refuse it outright. - name: Refuse to release an unprepared template if: startsWith(github.ref, 'refs/tags/') shell: pwsh diff --git a/Docs/CLASSES_AND_ENUMS.md b/Docs/CLASSES_AND_ENUMS.md new file mode 100644 index 0000000..3facd37 --- /dev/null +++ b/Docs/CLASSES_AND_ENUMS.md @@ -0,0 +1,33 @@ +# Classes and enums + +`Source/Enum/` and `Source/Classes/` hold one `.ps1` file per type. PowerShell classes follow +rules that plain functions do not, and each one costs an afternoon when you hit it blind. +This file documents the template, so `./tasks.ps1 prepare` deletes it; a short version +survives in the generated README. + +## Rules + +**Files must be `.ps1`.** ModuleBuilder and the development loader both ignore `.psm1` files +in the source directories. `using module` pointed at a `.ps1` file always fails, with an error +that misleadingly names a type inside the target file. Together that means one class file can +never `using module` another; load order is what makes cross-file references work. + +**Load order is alphabetical by full path, on both trees.** Nothing sorts by dependency. A base +class must sort before its derived class or the import dies with `Unable to find type [Base]`, +even inside the concatenated `.psm1`: forward references to a base class do not resolve. Use +numeric prefixes (`01-Message.ps1`) or numeric subdirectories (`Classes/00_Base/` sorts before +`Classes/Alert.ps1`). One edge case: `Message.Alert.ps1` sorts before `Message.ps1`, because +`.` sorts before letters. Enums already load before classes via the `SourceDirectories` order +in `build.psd1`. + +**Callers do not see your types.** `Import-Module` never exposes classes or enums; that is a +PowerShell rule. `using module` on the built manifest does, but never on the source manifest, +because dot-sourced classes stay invisible to it. So behaviour tests that go through public +functions run against either tree, while a test that writes `[Alert]::new(...)` belongs in +`Tests/Module.Tests.ps1`, the artifact suite, with `using module` on `Get-BuiltManifestPath`. +If callers should get your types from plain `Import-Module`, see +[Export classes with type accelerators](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_classes#export-classes-with-type-accelerators); it works from both trees but adds a session-global registration you +maintain by hand, so the template leaves it out. + +**`using namespace` in source files is fine.** ModuleBuilder hoists `using` statements to the +top of the built `.psm1`, and a dot-sourced file applies its own, so it works in both trees. diff --git a/README.md b/README.md index 7dc8858..b6aee21 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,15 @@ lint with PSScriptAnalyzer, and ship to the PowerShell Gallery from a git tag. | | | |---|---| | ๐Ÿ—๏ธ **ModuleBuilder** | Builds your module from source into a clean, versioned `Dist` output | -| ๐Ÿงช **Pester 5** | Test runner wired to `Tests/`, running against the **built** module | +| ๐Ÿงช **Pester 5** | Test runner wired to `Tests/`: behaviour tests run against the **source** tree (failures name a source file and line), artifact checks against the **built** module | | ๐Ÿ” **PSScriptAnalyzer** | Style and correctness pass, plus a compatibility pass against your target hosts | -| ๐Ÿ“Š **Code coverage** | Per-command coverage report with an optional minimum-percentage gate | +| ๐Ÿ“Š **Code coverage** | Per-file coverage report over the source tree with an optional minimum-percentage gate | | ๐ŸŽฏ **Task runner** | One entry point (`tasks.ps1`) for every tool | | ๐Ÿค– **GitHub Actions** | CI matrix across your target hosts, plus a tag-driven Gallery release | | ๐Ÿงฉ **Platform presets** | `PowerShell5.1`, `PowerShell7`, or both. One key sets the manifest, the lint targets, and the CI matrix | | ๐Ÿช„ **`prepare` task** | Renames and stamps the whole template from a single `module.psd1` | | ๐Ÿ” **Hardening guide** | The GitHub rulesets and settings that make publishing to the Gallery safe | -| ๐Ÿ“ **Structured source** | `Source/` layout with `Enum`, `Classes`, `Private`, and `Public` | +| ๐Ÿ“ **Structured source** | `Source/` layout with `Enum`, `Classes`, `Private`, and `Public`. Classes and enums have rules of their own: see [Docs/CLASSES_AND_ENUMS.md](Docs/CLASSES_AND_ENUMS.md) | --- @@ -149,13 +149,15 @@ assembled into the built module. โ”‚ โ”œโ”€โ”€ Private/ โ”‚ โ””โ”€โ”€ Public/ โ”œโ”€โ”€ ๐Ÿงช Tests/ -โ”‚ โ”œโ”€โ”€ _TestHelpers.ps1 # Imports the BUILT module; no module name hardcoded -โ”‚ โ””โ”€โ”€ Module.Tests.ps1 # Example suite, green on a fresh clone +โ”‚ โ”œโ”€โ”€ _TestHelpers.ps1 # Target selection and import helpers; no module name hardcoded +โ”‚ โ”œโ”€โ”€ Harness.Tests.ps1 # Tests for the test harness itself +โ”‚ โ””โ”€โ”€ Module.Tests.ps1 # Artifact checks against the BUILT module, green on a fresh clone โ”œโ”€โ”€ ๐Ÿ”ง Tools/ โ”‚ โ”œโ”€โ”€ platforms/ # Target-platform presets (removed by prepare) โ”‚ โ”œโ”€โ”€ templates/ # Skeletons rendered by prepare (removed by prepare) โ”‚ โ””โ”€โ”€ ... # See Tools/README.md โ”œโ”€โ”€ ๐Ÿ” Docs/HARDENING.md # GitHub settings to set before publishing (survives prepare) +โ”œโ”€โ”€ ๐Ÿ“š Docs/CLASSES_AND_ENUMS.md # Class/enum rules and limits (removed by prepare) โ”œโ”€โ”€ ๐Ÿค– .github/workflows/ # ci.yml (matrix from the platform) and release.yml (tag -> PSGallery) โ””โ”€โ”€ ๐Ÿ“ฆ Dist/ # Build output (gitignored, created by build) ``` @@ -174,9 +176,9 @@ assembled into the built module. | ๐Ÿงน | **cleanup** | One-time teardown. Deletes `prepare.ps1` and itself and strips both tasks out of `tasks.ps1`. Run once the repo is prepared and hardened. | | ๐Ÿ“ฅ | **install_dev_requirements** | Installs ModuleBuilder, Configuration, Pester 5+, PSScriptAnalyzer, plus your extras. Once per host **per PowerShell edition**. | | ๐Ÿ—๏ธ | **build** | Clears `Dist/`, builds with ModuleBuilder into `Dist//`. | -| ๐Ÿงช | **test** | Runs the Pester suite against the built module. Fails on an empty run. | +| ๐Ÿงช | **test** | Builds, then runs the Pester suite. `-Target Source` (default) or `Dist` picks the tree the behaviour tests import; `-Path ` picks which tests execute. Fails on an empty run. | | ๐Ÿ” | **lint** | PSScriptAnalyzer over `Source/`: style and correctness, then compatibility against your target platform. | -| ๐Ÿ“Š | **coverage** | Coverage report over the built module. `-MinimumPercent 90` to gate. | +| ๐Ÿ“Š | **coverage** | Per-file coverage report over the source tree. `-MinimumPercent 90` to gate. | | ๐Ÿšข | **prepare_release** | `./tasks.ps1 prepare_release 1.1.0`. Gates, promotes the changelog, stamps the version, rebuilds, verifies. | ๐Ÿ“– Full tool reference: **[Tools/README.md](Tools/README.md)** diff --git a/Source/ModuleTemplate.psm1 b/Source/ModuleTemplate.psm1 index b58f1e1..d424b11 100644 --- a/Source/ModuleTemplate.psm1 +++ b/Source/ModuleTemplate.psm1 @@ -4,7 +4,9 @@ foreach ($dir in 'Enum', 'Classes', 'Private', 'Public') { $path = Join-Path $PSScriptRoot $dir if (Test-Path -LiteralPath $path) { - foreach ($file in Get-ChildItem -Path $path -Filter '*.ps1') { + # -Recurse and Sort-Object FullName mirror how ModuleBuilder walks and concatenates the + # tree, so source and built module load files (and thus classes) in the same order. + foreach ($file in Get-ChildItem -Path $path -Filter '*.ps1' -Recurse | Sort-Object FullName) { . $file.FullName } } diff --git a/Tests/Harness.Tests.ps1 b/Tests/Harness.Tests.ps1 new file mode 100644 index 0000000..c91cb75 --- /dev/null +++ b/Tests/Harness.Tests.ps1 @@ -0,0 +1,14 @@ +# Tests for the test harness itself, not for the module. + +Describe 'Test runner' { + # Regression test: tests.ps1 must lower $ErrorActionPreference to 'Continue' around + # Invoke-Pester, or Write-Error below throws instead of writing to the stream. + It 'lets a command write a non-terminating error to the stream' { + function Invoke-ErrorWriter { [CmdletBinding()] param() Write-Error 'expected'; 'result' } + $out = Invoke-ErrorWriter 2>&1 + $errors = @($out | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) + $errors.Count | Should -Be 1 + $errors[0].ToString() | Should -Match 'expected' + $out | Where-Object { $_ -eq 'result' } | Should -Not -BeNullOrEmpty + } +} diff --git a/Tests/Module.Tests.ps1 b/Tests/Module.Tests.ps1 index c735d6f..35dde35 100644 --- a/Tests/Module.Tests.ps1 +++ b/Tests/Module.Tests.ps1 @@ -1,12 +1,28 @@ -# Example suite. It only asserts things that hold for an empty module, so it is green on a -# fresh clone and 'tests.ps1' has something to run. Keep it, extend it, or replace it as you -# add functions under Source/Public - one .Tests.ps1 per area is the usual shape. +# The artifact tests, the only file that reads the build output. Green on a fresh clone; +# behaviour tests go in their own .Tests.ps1 and import via Import-ModuleUnderTest. + +# Top level too, not just BeforeAll: -Skip: expressions run at discovery time. +. $PSScriptRoot/_TestHelpers.ps1 + +# A fresh clone defines no functions; the function-level checks skip then. +$SourceFunctionFiles = @(Get-ChildItem -Path (Get-ModuleInfo).SourceRoot -Recurse -Filter '*.ps1' -File | + Where-Object { $_.Name -ne "$((Get-ModuleInfo).ModuleName).psm1" }) +$SourceDefinesFunctions = [bool]($SourceFunctionFiles | Where-Object { + $ast = [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref] $null, [ref] $null) + $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) + }) + BeforeAll { . $PSScriptRoot/_TestHelpers.ps1 Import-BuiltModule } Describe 'Built module' { + It 'was built from the source tree as it stands now' { + $stale = Get-StaleBuildReason + $stale | Should -BeNullOrEmpty -Because "$stale" + } + It 'produced a manifest under Dist' { Get-BuiltManifestPath | Should -Exist } @@ -19,3 +35,61 @@ Describe 'Built module' { Get-Module -Name (Get-ModuleInfo).ModuleName | Should -Not -BeNullOrEmpty } } + +# A function the build misses only fails on a real install; catch it here instead. +Describe 'Every function in the source tree reaches the built module' -Skip:(-not $SourceDefinesFunctions) { + BeforeAll { + $script:Info = Get-ModuleInfo + + function script:Get-DefinedFunction { + [OutputType([string[]])] + param([string[]] $Path) + + $names = [System.Collections.Generic.HashSet[string]]::new() + foreach ($file in $Path) { + $ast = [System.Management.Automation.Language.Parser]::ParseFile($file, [ref] $null, [ref] $null) + foreach ($function in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { + [void] $names.Add($function.Name) + } + } + [string[]] $names + } + + $script:InSource = @(Get-DefinedFunction -Path @(Get-ChildItem -Path $script:Info.SourceRoot -Recurse -Filter '*.ps1' -File | + ForEach-Object { $_.FullName })) + $script:InBuild = @(Get-DefinedFunction -Path @(Join-Path (Split-Path -Parent (Get-BuiltManifestPath)) "$($script:Info.ModuleName).psm1")) + } + + It 'finds functions on both sides at all' { + $script:InSource.Count | Should -BeGreaterThan 0 + $script:InBuild.Count | Should -BeGreaterThan 0 + } + + It 'defines every function the source tree defines' { + $missing = @($script:InSource | Where-Object { $_ -notin $script:InBuild } | Sort-Object) + $missing | Should -BeNullOrEmpty -Because "the source tree defines these and the built module does not: $($missing -join ', ')" + } +} + +# Nothing else keeps build.psd1's directory list and the dev loader's loop in step. +Describe 'The build configuration and the development loader' { + BeforeAll { + $script:Info = Get-ModuleInfo + $script:Configured = @((Import-PowerShellDataFile -LiteralPath (Join-Path $script:Info.RepoRoot 'build.psd1')).SourceDirectories) + + $loader = Join-Path $script:Info.SourceRoot "$($script:Info.ModuleName).psm1" + $ast = [System.Management.Automation.Language.Parser]::ParseFile($loader, [ref] $null, [ref] $null) + $loop = $ast.Find({ + param($n) + $n -is [System.Management.Automation.Language.ForEachStatementAst] -and $n.Variable.VariablePath.UserPath -eq 'dir' + }, $true) + $script:Loaded = @($loop.Condition.FindAll({ param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] }, $true) | + ForEach-Object { $_.Value }) + } + + It 'walks the same source directories on both sides' { + $script:Configured.Count | Should -BeGreaterThan 0 + $both = "build.psd1 builds from '$($script:Configured -join ', ')' and the development loader dot-sources '$($script:Loaded -join ', ')'" + @($script:Loaded | Sort-Object) | Should -Be @($script:Configured | Sort-Object) -Because $both + } +} diff --git a/Tests/_TestHelpers.ps1 b/Tests/_TestHelpers.ps1 index 105613e..b43bf58 100644 --- a/Tests/_TestHelpers.ps1 +++ b/Tests/_TestHelpers.ps1 @@ -1,19 +1,64 @@ -# Shared by every *.Tests.ps1: import the BUILT module from Dist (NOT the source). -# ModuleBuilder only exports the public functions in the built module, so tests must run -# against the build output, the same artifact a user installs. -# -# Get-ModuleInfo / Get-BuiltManifestPath come from Tools/module_info.ps1, which resolves the -# module's name from build.psd1 - no name is hardcoded here, so this keeps working after -# './tasks.ps1 prepare' renames everything. +# Shared by every *.Tests.ps1: target selection and import helpers. Dot-sourced in BeforeAll, +# and at top level where a -Skip: expression needs it. Names and paths come from +# Tools/module_info.ps1, so nothing here breaks when prepare renames the module. . (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'Tools') 'module_info.ps1') +# 'Source' or 'Dist'. An environment variable, not a parameter: Pester evaluates -Skip: +# expressions at discovery time. Set via -Target on ./tasks.ps1 test. +function Get-TestTarget { + [OutputType([string])] + param() + $value = [Environment]::GetEnvironmentVariable((Get-TestTargetVariableName)) + if ($value -eq 'Dist') { 'Dist' } else { 'Source' } +} + +function Get-ModuleUnderTestPath { + [OutputType([string])] + param() + if ((Get-TestTarget) -eq 'Dist') { Get-BuiltManifestPath } else { (Get-ModuleInfo).SourceManifest } +} + +function Import-ModuleUnderTest { + Import-OneModule -Manifest (Get-ModuleUnderTestPath) +} + function Import-BuiltModule { - Import-Module (Get-BuiltManifestPath) -Force + Import-OneModule -Manifest (Get-BuiltManifestPath) +} + +# Unload by name first: Import-Module -Force loads a second module beside one imported from +# another path, leaving two modules of one name with doubled exports. +function Import-OneModule { + param( + [Parameter(Mandatory)] + [string] $Manifest + ) + Remove-Module -Name (Get-ModuleInfo).ModuleName -Force -ErrorAction SilentlyContinue + Import-Module $Manifest -Force +} + +# $null when the build is fresh, otherwise the sentence the artifact test fails with. Catches +# Pester run by hand against yesterday's build; ./tasks.ps1 test builds first anyway. +function Get-StaleBuildReason { + [OutputType([string])] + param() + $info = Get-ModuleInfo + $built = Get-ChildItem -Path $info.DistRoot -Recurse -Filter "$($info.ModuleName).psm1" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (-not $built) { + return "No built module under '$($info.DistRoot)'. Run './tasks.ps1 build'." + } + $newest = Get-ChildItem -Path $info.SourceRoot -Recurse -File -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($newest -and $newest.LastWriteTime -gt $built.LastWriteTime) { + return ("The built module is older than the source. '$($built.Name)' was built {0}, " -f $built.LastWriteTime.ToString('o')) + + ("'$($newest.Name)' was changed {0}. Run './tasks.ps1 build'." -f $newest.LastWriteTime.ToString('o')) + } + $null } -# Single definition of the host check used by -Skip: expressions, which Pester evaluates at -# DISCOVERY time - so this must work before any BeforeAll runs, and on Windows PowerShell 5.1 -# where $IsWindows does not exist. +# Host check for -Skip: expressions; works on Windows PowerShell 5.1 where $IsWindows +# does not exist. function Test-OnWindowsHost { [OutputType([bool])] param() diff --git a/Tools/README.md b/Tools/README.md index 2ee88ab..88eda99 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -20,9 +20,9 @@ Everything here is meant to be run from the **repo root**. Every tool has a shor | `cleanup.ps1` | `cleanup` | One-time teardown of the setup machinery: deletes `prepare.ps1` and itself, and strips `prepare`/`cleanup` out of `tasks.ps1`. Run it once the repo is prepared and hardened. Keeps `module.psd1` and `Docs/HARDENING.md`. | | `install_dev_requirements.ps1` | `install_dev_requirements` | Installs ModuleBuilder, Configuration, Pester 5+, PSScriptAnalyzer, plus anything in `module.psd1`'s `ModuleRequiredModules`, for the current user. | | `build.ps1` | `build` | Clears `Dist/` and builds the module with ModuleBuilder. | -| `tests.ps1` | `test` | Runs the Pester suite against the built module. Throws on any failure, and on an empty run. | +| `tests.ps1` | `test [-Target Source\|Dist] [-Path ]` | Builds, then runs the Pester suite. `-Target` picks the tree the behaviour tests import (default `Source`); the artifact tests always read the build output. `-Path` picks which test files execute. Throws on any failure, and on an empty run. | | `lint.ps1` | `lint` | PSScriptAnalyzer over `Source/`: style/correctness, then WinPS 5.1 + pwsh 7 compatibility. Any finding fails. | -| `coverage.ps1` | `coverage` | Test run with code coverage, listing every missed command. `-MinimumPercent` gates the run. | +| `coverage.ps1` | `coverage` | Builds, then measures coverage over the source `.ps1` files, printing a per-file percentage and every missed command as `file:line: command`. `-MinimumPercent` gates the run. | | `prepare_release.ps1` | `prepare_release ` | Runs the gates, promotes the changelog, stamps the version, rebuilds, and verifies the built manifest. | | `get_changelog_section.ps1` | - | Extracts one section from `CHANGELOG.md`. Shared by `prepare_release` and the release workflow. | | `module_info.ps1` | - | Resolves the module's name and paths from `build.psd1`. Dot-sourced by the other tools and by `Tests/_TestHelpers.ps1` so no module name is ever hardcoded. | @@ -30,8 +30,10 @@ Everything here is meant to be run from the **repo root**. Every tool has a shor | `platforms/` | - | Target-platform presets. Deleted once `prepare` has run. | | `templates/` | - | Skeletons rendered by `prepare.ps1`. Deleted once `prepare` has run. | -Every tool imports the **built** module from `Dist/`, the same artifact a user installs, so -`build` has to run before `test` or `coverage`. +`test` and `coverage` build first themselves, so a run can never verify a stale artifact. The +behaviour tests import the **source** tree by default, so a failure names a source file and +line; the artifact tests in `Tests/Module.Tests.ps1` always read the **built** module from +`Dist/`, the same artifact a user installs. ## Notes diff --git a/Tools/cleanup.ps1 b/Tools/cleanup.ps1 index 7a1f412..c4b230c 100644 --- a/Tools/cleanup.ps1 +++ b/Tools/cleanup.ps1 @@ -8,7 +8,7 @@ nothing left to do. This deletes it: - Tools/prepare.ps1 - any scaffolding prepare would normally have removed already (res/, Tools/templates/, - Tools/platforms/), in case the repo was prepared by hand + Tools/platforms/, Docs/CLASSES_AND_ENUMS.md), in case the repo was prepared by hand - the 'prepare' and 'cleanup' entries in tasks.ps1, and the -Platform parameter that only prepare used - the prepare/cleanup rows in Tools/README.md @@ -86,7 +86,7 @@ try { Write-Host "Module: $($info.ModuleName)" # --- 2) Drop scaffolding prepare should already have removed -------------------------- - foreach ($leftover in 'res', (Join-Path 'Tools' 'templates'), (Join-Path 'Tools' 'platforms')) { + foreach ($leftover in 'res', (Join-Path 'Tools' 'templates'), (Join-Path 'Tools' 'platforms'), (Join-Path 'Docs' 'CLASSES_AND_ENUMS.md')) { $path = Join-Path $repoRoot $leftover if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force diff --git a/Tools/coverage.ps1 b/Tools/coverage.ps1 index cfeec77..a3197aa 100644 --- a/Tools/coverage.ps1 +++ b/Tools/coverage.ps1 @@ -1,12 +1,11 @@ -# Runs the Pester suite with code coverage against the BUILT module and prints every missed -# command, so a gap can be traced back to a specific line. +# Runs the Pester suite with code coverage against the SOURCE tree and prints a percentage per +# source file plus every missed command. # # Usage (from the repo root): # ./tasks.ps1 coverage [-MinimumPercent 90] # pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File Tools/coverage.ps1 # -# Coverage is measured per host, and anything that runs in a child process reads as missed -# because the instrumentation cannot follow it. Compare hosts before calling a line untested. +# Coverage is measured per host. Compare hosts before calling a line untested. param( # Exit non-zero when coverage falls below this percentage. 0 disables the gate. @@ -15,14 +14,17 @@ param( $ErrorActionPreference = 'Stop' +# Build first so a run can never verify a stale artifact. +. $(Join-Path $PSScriptRoot 'build.ps1') + . $(Join-Path $PSScriptRoot 'module_info.ps1') $info = Get-ModuleInfo -# Cover the built .psm1 (the artifact a user installs), not the Source/ files it was -# concatenated from. -$psm1 = (Get-ChildItem -Path $info.DistRoot -Recurse -Filter "$($info.ModuleName).psm1" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName -if (-not $psm1) { throw "Built module not found under '$($info.DistRoot)'. Run './tasks.ps1 build' first." } +$sourceFiles = @(Get-ChildItem -Path $info.SourceRoot -Recurse -Filter '*.ps1' -File | + Where-Object { $_.Name -notlike '*.Tests.ps1' } | ForEach-Object { $_.FullName }) +if (-not $sourceFiles.Count) { + Write-Host "No *.ps1 files under '$($info.SourceRoot)' yet; running the suite without coverage." +} Remove-Module Pester -Force -ErrorAction SilentlyContinue Import-Module Pester -MinimumVersion 5.0.0 -Force @@ -31,21 +33,55 @@ $c = New-PesterConfiguration $c.Run.Path = Join-Path $info.RepoRoot 'Tests' $c.Run.PassThru = $true $c.Output.Verbosity = 'None' -$c.CodeCoverage.Enabled = $true -$c.CodeCoverage.Path = $psm1 +if ($sourceFiles.Count) { + $c.CodeCoverage.Enabled = $true + $c.CodeCoverage.Path = $sourceFiles +} + +$strict = $ErrorActionPreference +$targetVariable = Get-TestTargetVariableName +$previousTarget = [Environment]::GetEnvironmentVariable($targetVariable) +try { + $ErrorActionPreference = 'Continue' + [Environment]::SetEnvironmentVariable($targetVariable, 'Source') + $r = Invoke-Pester -Configuration $c +} finally { + [Environment]::SetEnvironmentVariable($targetVariable, $previousTarget) + $ErrorActionPreference = $strict +} + +Write-Host ("Tests: {0} passed, {1} failed" -f $r.PassedCount, $r.FailedCount) +if ($r.FailedCount -gt 0) { exit 1 } +if (-not $sourceFiles.Count) { return } -$r = Invoke-Pester -Configuration $c $cc = $r.CodeCoverage $pct = if ($cc.CommandsAnalyzedCount) { $cc.CommandsExecutedCount / $cc.CommandsAnalyzedCount * 100 } else { 0 } -Write-Host ("Tests: {0} passed, {1} failed" -f $r.PassedCount, $r.FailedCount) Write-Host ("Coverage: {0}/{1} = {2:N1}%" -f $cc.CommandsExecutedCount, $cc.CommandsAnalyzedCount, $pct) + +$perFile = @{} +foreach ($pair in @(@{ Set = $cc.CommandsExecuted; Hit = $true }, @{ Set = $cc.CommandsMissed; Hit = $false })) { + foreach ($command in @($pair.Set)) { + $name = Split-Path -Leaf $command.File + if (-not $perFile.ContainsKey($name)) { $perFile[$name] = @{ Executed = 0; Analyzed = 0 } } + $perFile[$name].Analyzed++ + if ($pair.Hit) { $perFile[$name].Executed++ } + } +} + +Write-Host "--- Per file ---" +foreach ($name in ($perFile.Keys | Sort-Object)) { + $file = $perFile[$name] + Write-Host (" {0,6:N1}% {1,4}/{2,-4} {3}" -f (($file.Executed / $file.Analyzed) * 100), $file.Executed, $file.Analyzed, $name) +} + if ($cc.CommandsMissed.Count) { Write-Host "--- Missed ---" - $cc.CommandsMissed | ForEach-Object { Write-Host (" {0}: {1}" -f $_.Line, $_.Command) } + $cc.CommandsMissed | Sort-Object File, Line | ForEach-Object { + Write-Host (" {0}:{1}: {2}" -f (Split-Path -Leaf $_.File), $_.Line, $_.Command) + } } -if ($r.FailedCount -gt 0) { exit 1 } if ($pct -lt $MinimumPercent) { Write-Host ("Coverage {0:N1}% is below the {1:N1}% threshold." -f $pct, $MinimumPercent) exit 1 diff --git a/Tools/lint.ps1 b/Tools/lint.ps1 index 9f730e2..4e8b2fa 100644 --- a/Tools/lint.ps1 +++ b/Tools/lint.ps1 @@ -20,12 +20,8 @@ if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) { Write-Host "Installing PSScriptAnalyzer..." Install-Module PSScriptAnalyzer -Force -Scope CurrentUser -SkipPublisherCheck -ErrorAction Stop } -# Gate on the COMMAND being callable, not on the module being listed: a session can have -# PSScriptAnalyzer in Get-Module yet not expose Invoke-ScriptAnalyzer (a half-finished -# import, or an editor that loaded it into another state), and a module-presence check -# would then skip the repairing import and fail at the first Invoke-ScriptAnalyzer call. -# No -Force: on an already-working module it would throw 'Assembly with same name is -# already loaded'; when the command is missing, a plain Import-Module brings it back. +# Gate on the command, not the module: a listed module can still lack Invoke-ScriptAnalyzer. +# No -Force: it throws 'Assembly with same name is already loaded' on a working module. if (-not (Get-Command Invoke-ScriptAnalyzer -ErrorAction SilentlyContinue)) { Import-Module PSScriptAnalyzer } diff --git a/Tools/module_info.ps1 b/Tools/module_info.ps1 index e6b7666..ce7b58e 100644 --- a/Tools/module_info.ps1 +++ b/Tools/module_info.ps1 @@ -44,6 +44,21 @@ function Get-ModuleInfo { } } +function Get-TestTargetVariableName { + <# + .SYNOPSIS + Name of the environment variable that tells the test suite which tree to import. + #> + [OutputType([string])] + param( + [string]$RepoRoot = (Split-Path -Parent $PSScriptRoot) + ) + + # Derived from the module name, so it follows a prepare rename automatically. + $name = (Get-ModuleInfo -RepoRoot $RepoRoot).ModuleName -replace '[^A-Za-z0-9]', '_' + "$($name.ToUpperInvariant())_TEST_TARGET" +} + function Get-BuiltManifestPath { <# .SYNOPSIS diff --git a/Tools/prepare.ps1 b/Tools/prepare.ps1 index 83549d5..07054ee 100644 --- a/Tools/prepare.ps1 +++ b/Tools/prepare.ps1 @@ -330,7 +330,7 @@ try { } # --- 8) Drop the template-only scaffolding ------------------------------------------- - foreach ($leftover in 'res', (Join-Path 'Tools' 'templates'), (Join-Path 'Tools' 'platforms')) { + foreach ($leftover in 'res', (Join-Path 'Tools' 'templates'), (Join-Path 'Tools' 'platforms'), (Join-Path 'Docs' 'CLASSES_AND_ENUMS.md')) { $path = Join-Path $repoRoot $leftover if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force diff --git a/Tools/templates/README.md b/Tools/templates/README.md index 30d8522..e1f7bc5 100644 --- a/Tools/templates/README.md +++ b/Tools/templates/README.md @@ -48,14 +48,20 @@ See [Tools/README.md](Tools/README.md) for the full tool reference. โ”‚ โ”œโ”€โ”€ Classes/ # One .ps1 per class โ”‚ โ”œโ”€โ”€ Private/ # Internal helpers, not exported โ”‚ โ””โ”€โ”€ Public/ # One .ps1 per exported function -โ”œโ”€โ”€ Tests/ # Pester tests, run against the BUILT module +โ”œโ”€โ”€ Tests/ # Pester tests; behaviour tests import Source, artifact checks the build โ”œโ”€โ”€ Tools/ # Build, test, lint, coverage, release tooling โ”œโ”€โ”€ Docs/HARDENING.md # GitHub settings to set before publishing โ””โ”€โ”€ Dist/ # Build output (gitignored) ``` -Everything in `Tools/` imports the **built** module from `Dist/`, the same artifact a user -installs, so `build` has to run before `test` or `coverage`. +`test` and `coverage` build first themselves. Behaviour tests run against `Source/` by default +(`./tasks.ps1 test -Target Dist` switches); the artifact tests in `Tests/Module.Tests.ps1` +always read the built module from `Dist/`, the same artifact a user installs. + +Classes and enums: files must be `.ps1`, they load in alphabetical full-path order on both +trees (name base classes so they sort before derived ones), and `using module` between source +files does not work. Tests that name a class type literally belong in `Tests/Module.Tests.ps1`, +reached via `using module` on the built manifest. ## Releasing diff --git a/Tools/templates/ci.yml b/Tools/templates/ci.yml index 89ee205..8e1b562 100644 --- a/Tools/templates/ci.yml +++ b/Tools/templates/ci.yml @@ -20,25 +20,17 @@ jobs: # {{PlatformDescription}} # Edit freely - nothing regenerates it. Keep it in sync with the target profiles in # Tools/PSScriptAnalyzer.psd1 and CompatiblePSEditions in the module manifest. - # - # modulePath is the host's CurrentUser module folder, cached below so the slow - # first Install-Module (minutes under WinPS 5.1's inbox PowerShellGet) is paid - # only when Tools/install_dev_requirements.ps1 or module.psd1 changes. include: {{Matrix}} steps: - uses: actions/checkout@v7 - # On a cache hit the install script finds the modules via Get-Module -ListAvailable - # and skips Install-Module entirely. - name: Cache PowerShell modules uses: actions/cache@v6 with: path: ${{ matrix.modulePath }} key: psmodules-${{ matrix.os }}-${{ matrix.host }}-${{ hashFiles('Tools/install_dev_requirements.ps1', 'module.psd1') }} - # matrix.host selects the runtime: 'powershell' = Windows PowerShell 5.1, 'pwsh' = PowerShell 7+. - # The shell key cannot use the matrix context, so run under pwsh and invoke the target host. - name: Install dev requirements shell: pwsh run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task install_dev_requirements @@ -51,6 +43,10 @@ jobs: shell: pwsh run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task build - - name: Test + - name: Test (source tree) shell: pwsh - run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test + run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test -Target Source + + - name: Test (built module, artifact checks) + shell: pwsh + run: ${{ matrix.host }} -NoProfile -ExecutionPolicy Bypass -File ./tasks.ps1 -Task test -Target Dist -Path Tests/Module.Tests.ps1 diff --git a/Tools/tests.ps1 b/Tools/tests.ps1 index 8f30163..f3c213c 100644 --- a/Tools/tests.ps1 +++ b/Tools/tests.ps1 @@ -1,26 +1,48 @@ -# Runs the Pester suite against the BUILT module in Dist/ (build first). Throws on any test -# failure, so it can gate a release or a CI job. +# Builds the module, then runs the Pester suite against the tree -Target names. Throws on any +# test failure, so it can gate a release or a CI job. # # Usage (from the repo root): # ./tasks.ps1 test -# pwsh -File Tools/tests.ps1 [-PassThru] +# ./tasks.ps1 test -Target Dist -Path Tests/Module.Tests.ps1 +# pwsh -File Tools/tests.ps1 [-Target Source|Dist] [-Path ] [-PassThru] param( + # Which tree the behaviour suite imports. The artifact tests always read the build output. + [ValidateSet('Source', 'Dist')] + [string]$Target = 'Source', + + # Which tests to run, a file or a directory. Defaults to the whole test directory. + [string]$Path, + # Emit the Pester result object as well, for a caller that wants the counts. [switch]$PassThru ) $ErrorActionPreference = 'Stop' +. $(Join-Path $PSScriptRoot 'module_info.ps1') + +# Build first so a run can never verify a stale artifact. +. $(Join-Path $PSScriptRoot 'build.ps1') + # Force Pester v5+. On Windows PowerShell 5.1 the built-in Pester 3.4 would otherwise load and # New-PesterConfiguration would not exist, silently skipping the whole suite. Remove-Module Pester -Force -ErrorAction SilentlyContinue Import-Module Pester -MinimumVersion 5.0.0 -Force # Join-Path's 3-argument form is PowerShell 7+ only; nest for Windows PowerShell 5.1. -$testPath = Join-Path (Join-Path $PSScriptRoot '..') 'Tests' | Resolve-Path -ErrorAction SilentlyContinue -if (-not $testPath -or -not (Get-ChildItem -Path $testPath -Filter '*.Tests.ps1' -Recurse -ErrorAction SilentlyContinue)) { - throw "No *.Tests.ps1 files found under Tests/. Refusing to report success on an empty run." +$requested = if ($Path) { $Path } else { Join-Path (Join-Path $PSScriptRoot '..') 'Tests' } +$testPath = Resolve-Path -Path $requested -ErrorAction SilentlyContinue +if (-not $testPath) { + throw "Test path '$requested' does not exist. Refusing to report success on an empty run." +} +$found = if (Test-Path -LiteralPath $testPath.Path -PathType Container) { + Get-ChildItem -Path $testPath.Path -Filter '*.Tests.ps1' -Recurse -ErrorAction SilentlyContinue +} else { + Get-Item -LiteralPath $testPath.Path -ErrorAction SilentlyContinue | Where-Object { $_.Name -like '*.Tests.ps1' } +} +if (-not $found) { + throw "No *.Tests.ps1 files found under '$($testPath.Path)'. Refusing to report success on an empty run." } $config = New-PesterConfiguration @@ -30,7 +52,18 @@ $config.Run.Path = $testPath.Path # letting the throw below propagate. $config.Run.PassThru = $true $config.Output.Verbosity = 'Detailed' -$result = Invoke-Pester -Configuration $config + +$strict = $ErrorActionPreference +$targetVariable = Get-TestTargetVariableName +$previousTarget = [Environment]::GetEnvironmentVariable($targetVariable) +try { + $ErrorActionPreference = 'Continue' + [Environment]::SetEnvironmentVariable($targetVariable, $Target) + $result = Invoke-Pester -Configuration $config +} finally { + [Environment]::SetEnvironmentVariable($targetVariable, $previousTarget) + $ErrorActionPreference = $strict +} if (-not $result -or $result.FailedCount -gt 0) { throw "Tests failed ($($result.FailedCount) failed / $($result.TotalCount) total)." diff --git a/tasks.ps1 b/tasks.ps1 index 58560ee..83ae078 100644 --- a/tasks.ps1 +++ b/tasks.ps1 @@ -14,7 +14,12 @@ param( # Only used by prepare: overrides ModuleTargetPlatform from module.psd1. # One of the presets in Tools/platforms/ (PowerShell5.1, PowerShell7, PowerShell5.1And7). - [string]$Platform + [string]$Platform, + + # Only used by test: which tree the behaviour suite imports, and which test files execute. + [ValidateSet('Source', 'Dist')] + [string]$Target = 'Source', + [string]$Path ) switch ($Task) { @@ -23,7 +28,10 @@ switch ($Task) { break } 'test' { - . $(Join-Path "Tools" "tests.ps1") + # Splatted so an unset -Path falls through to the default rather than an empty string. + $testArgs = @{ Target = $Target } + if ($Path) { $testArgs.Path = $Path } + . $(Join-Path "Tools" "tests.ps1") @testArgs break } 'lint' {