From 7281471171a4f000b6dd216c8d6657aa75605e23 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 27 May 2026 00:32:10 -0400 Subject: [PATCH 1/2] test: align Manifest test with canonical template Bring tests/Manifest.tests.ps1 up to the current PowerShellModuleTemplate shape and add tests/ManifestHelpers.psm1 (copied verbatim) for SemVer-aware version-constraint checks. Replaces the single-version Module Dependency context with the canonical five-test version that validates the manifest's RequiredModules against the dependency file via Test-VersionConstraint (ModuleVersion/RequiredVersion/MaximumVersion -> LessOrEqual/Equal/ GreaterOrEqual), with clear skips for plain-string references and missing/empty versions. Adds $manifestRawData, and carries over the template-only $isTemplate skip plus the skipped 'Git tagging' Describe for fidelity. ReScenePS adaptations: - Reads runtime.depend.psd1 (this repo's renamed requirements file) instead of requirements.psd1. - Preserves the psake Set-BuildEnvironment bootstrap (repo-wide isolation pattern), corrected to the scoped named-parameter rule. - Preserves the foreach changelog parse over the template's ForEach-Object { ... break } (break is unreliable in ForEach-Object). Deferred follow-up to #18. Build + Pester pass locally: 786 passed, 0 failed, 23 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Manifest.tests.ps1 | 214 ++++++++++++++++++-- tests/ManifestHelpers.psm1 | 387 +++++++++++++++++++++++++++++++++++++ 2 files changed, 582 insertions(+), 19 deletions(-) create mode 100644 tests/ManifestHelpers.psm1 diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index 45813e8..d790c27 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -1,8 +1,14 @@ +# spell-checker:ignore BHPS oneline [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', 'changelogVersion', Justification = 'false positive' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'gitTagVersion', + Justification = 'false positive' +)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', 'manifestData', @@ -18,6 +24,36 @@ 'dependencies', Justification = 'false positive' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'dependencyName', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'dependencyRawData', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'manifestRawData', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'requirementsVersionSkipReason', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'requirementsVersion', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'candidateVersion', + Justification = 'false positive' +)] param() BeforeDiscovery { @@ -30,7 +66,7 @@ BeforeDiscovery { # the values it needs (BHPSModuleManifest, BHProjectName) — when running # via ./build.ps1 this happens before psake; running tests in isolation # bypasses that, so we do it here. - Set-BuildEnvironment -Path (Split-Path -Parent $PSScriptRoot) -Force + Set-BuildEnvironment -Path (Split-Path -Path $PSScriptRoot -Parent) -Force $buildFilePath = Join-Path -Path $PSScriptRoot -ChildPath '..\build.psake.ps1' $invokePsakeParameters = @{ TaskList = 'Build' @@ -40,10 +76,10 @@ BeforeDiscovery { } # PowerShellBuild outputs to Output///, override BHBuildOutput - $projectRoot = Split-Path -Parent $PSScriptRoot - $sourceManifest = Join-Path $projectRoot "$Env:BHProjectName/$Env:BHProjectName.psd1" - $moduleVersion = (Import-PowerShellDataFile -Path $sourceManifest).ModuleVersion - $Env:BHBuildOutput = Join-Path $projectRoot "Output/$Env:BHProjectName/$moduleVersion" + $projectRoot = Split-Path -Path $PSScriptRoot -Parent + $sourceManifest = Join-Path -Path $projectRoot -ChildPath "$Env:BHProjectName/$Env:BHProjectName.psd1" + $moduleVersion = (Import-PowerShellDataFile $sourceManifest).ModuleVersion + $Env:BHBuildOutput = Join-Path -Path $projectRoot -ChildPath "Output/$Env:BHProjectName/$moduleVersion" # Define the path to the module manifest $moduleManifestFilename = $Env:BHProjectName + '.psd1' @@ -57,6 +93,14 @@ BeforeDiscovery { } $manifestData = Test-ModuleManifest @testModuleManifestParameters $dependencies = $manifestData.RequiredModules + + # When running on the un-initialized template, CHANGELOG.md tracks the template's + # CalVer version (YYYY.MM.DD), which deliberately decouples from the manifest's + # ModuleVersion. Skip the equality assertion in that case; downstream modules (post-init) + # keep the assertion. Marker: CHANGELOG.template.md exists only pre-init — + # Initialize-Template.ps1 moves it onto CHANGELOG.md during init. The marker survives + # the init substitution loop because no token in the path matches a {{Placeholder}}. + $isTemplate = Test-Path -LiteralPath (Join-Path -Path $Env:BHProjectPath -ChildPath 'CHANGELOG.template.md') } BeforeAll { <# Check if the BHBuildOutput environment variable exists to determine if this test is running in a psake @@ -68,7 +112,7 @@ BeforeAll { # the values it needs (BHPSModuleManifest, BHProjectName) — when running # via ./build.ps1 this happens before psake; running tests in isolation # bypasses that, so we do it here. - Set-BuildEnvironment -Path (Split-Path -Parent $PSScriptRoot) -Force + Set-BuildEnvironment -Path (Split-Path -Path $PSScriptRoot -Parent) -Force $buildFilePath = Join-Path -Path $PSScriptRoot -ChildPath '..\build.psake.ps1' $invokePsakeParameters = @{ TaskList = 'Build' @@ -78,10 +122,10 @@ BeforeAll { } # PowerShellBuild outputs to Output///, override BHBuildOutput - $projectRoot = Split-Path -Parent $PSScriptRoot - $sourceManifest = Join-Path $projectRoot "$Env:BHProjectName/$Env:BHProjectName.psd1" - $moduleVersion = (Import-PowerShellDataFile -Path $sourceManifest).ModuleVersion - $Env:BHBuildOutput = Join-Path $projectRoot "Output/$Env:BHProjectName/$moduleVersion" + $projectRoot = Split-Path -Path $PSScriptRoot -Parent + $sourceManifest = Join-Path -Path $projectRoot -ChildPath "$Env:BHProjectName/$Env:BHProjectName.psd1" + $moduleVersion = (Import-PowerShellDataFile $sourceManifest).ModuleVersion + $Env:BHBuildOutput = Join-Path -Path $projectRoot -ChildPath "Output/$Env:BHProjectName/$moduleVersion" # Define the path to the module manifest $moduleManifestFilename = $Env:BHProjectName + '.psd1' @@ -93,7 +137,21 @@ BeforeAll { ErrorAction = 'Stop' WarningAction = 'SilentlyContinue' } + $importDataFileParameters = @{ + Path = $moduleManifestPath + ErrorAction = 'Stop' + WarningAction = 'SilentlyContinue' + } $manifestData = Test-ModuleManifest @testModuleManifestParameters + $manifestRawData = Import-PowerShellDataFile @importDataFileParameters + + # Import ManifestHelpers.psm1 for SemVer helper functions + Import-Module (Join-Path -Path $PSScriptRoot -ChildPath 'ManifestHelpers.psm1') -Verbose:$false -Force + + # ReScenePS tracks runtime dependencies in runtime.depend.psd1 (a PSDepend requirements + # file) where the template uses requirements.psd1; the dependency-sync checks read from it. + $requirementsPath = Join-Path -Path $Env:BHProjectPath -ChildPath 'runtime.depend.psd1' + $requirements = Import-PowerShellDataFile $requirementsPath -ErrorAction 'Stop' # Parse the version from the changelog # Note: Use foreach statement (not ForEach-Object) because 'break' doesn't work reliably in ForEach-Object @@ -148,26 +206,144 @@ Describe 'Module manifest' { $changelogVersion -as [Version] | Should -Not -BeNullOrEmpty } - It 'Changelog and manifest versions are the same' { + It 'Changelog and manifest versions are the same' -Skip:$isTemplate { $changelogVersion -as [Version] | Should -Be ( $manifestData.Version -as [Version] ) } Context 'Module Dependency' -ForEach $dependencies { - # This ensures we keep our dependant modules in sync between the manifest file and the requirements - # script used to bootstrap and test. + # This ensures we keep our dependent modules in sync between the manifest file and the + # runtime.depend.psd1 requirements script used to bootstrap and test. BeforeAll { - $requirementsPath = Join-Path -Path $Env:BHProjectPath -Child 'runtime.depend.psd1' - $requirements = Import-PowerShellDataFile $requirementsPath + $dependencyName = $_.Name + $dependencyRawData = $manifestRawData.RequiredModules | Where-Object { + $_ -eq $dependencyName -or $_.ModuleName -eq $dependencyName + } + # Ensure exactly one match - duplicates should fail, not silently skip + if (@($dependencyRawData).Count -gt 1) { + throw "Duplicate RequiredModules entry found for '$dependencyName'" + } + # Handle plain-string module references (not hashtables with version info) + if ($dependencyRawData -isnot [hashtable]) { + $dependencyRawData = $null + } + + # Extract version from runtime.depend.psd1 (shared logic for all version constraint tests) + $requirementsVersionSkipReason = $null + $requirementsVersion = $null + + if (-not $requirements.ContainsKey($dependencyName)) { + $requirementsVersionSkipReason = 'dependency not found in runtime.depend.psd1' + } elseif ($requirements.Item($dependencyName) -is [string]) { + # Plain string format: 'ModuleName' = '1.2.3' + $candidateVersion = $requirements.Item($dependencyName) + if ([string]::IsNullOrWhiteSpace($candidateVersion)) { + $requirementsVersionSkipReason = "runtime.depend.psd1 entry for '$dependencyName' has an empty Version" + } else { + $requirementsVersion = $candidateVersion + } + } elseif ($requirements.Item($dependencyName) -is [hashtable] -and $requirements.Item($dependencyName).ContainsKey('Version')) { + # Hashtable format: 'ModuleName' = @{ Version = '1.2.3' } + $candidateVersion = $requirements.Item($dependencyName).Version + if ([string]::IsNullOrWhiteSpace($candidateVersion)) { + $requirementsVersionSkipReason = "runtime.depend.psd1 entry for '$dependencyName' has an empty Version" + } else { + $requirementsVersion = $candidateVersion + } + } else { + # Invalid format + $requirementsVersionSkipReason = "runtime.depend.psd1 entry for '$dependencyName' must be a string or hashtable with a Version key" + } } - It '<_.Name> exists in Requirements.psd1' { - $requirements.ContainsKey($_.Name) | Should -BeTrue + It '<_.Name> exists in runtime.depend.psd1' { + $requirements.ContainsKey($dependencyName) | Should -BeTrue } - It '<_.Name> has matching version in the Requirements.psd1' { - [Version]$requirements.Item($_.Name).Version | Should -Be $_.Version + It '<_.Name> uses at least one version key' { + if ($null -eq $dependencyRawData) { + Set-ItResult -Skipped -Because 'Plain-string module reference without version constraints' + } + + # Valid dependency version keys + $validDependencyKeys = @( + 'ModuleVersion' # Specifies a minimum acceptable version of the module + 'RequiredVersion' # Specifies an exact, required version of the module + 'MaximumVersion' # Specifies a maximum acceptable version of the module + ) + $dependencyKeysUsed = $dependencyRawData.Keys | Where-Object { $_ -in $validDependencyKeys } + $dependencyKeysUsed.Count | Should -BeGreaterThan 0 + } + + It '<_.Name> has a matching required version in runtime.depend.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('RequiredVersion')) { + Set-ItResult -Skipped -Because 'No RequiredVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.RequiredVersion + RequirementsVersion = $requirementsVersion + Constraint = 'Equal' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue + } + + It '<_.Name> has a maximum version greater than or equal to runtime.depend.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('MaximumVersion')) { + Set-ItResult -Skipped -Because 'No MaximumVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.MaximumVersion + RequirementsVersion = $requirementsVersion + Constraint = 'GreaterOrEqual' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue + } + + It '<_.Name> has a minimum version at or below runtime.depend.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('ModuleVersion')) { + Set-ItResult -Skipped -Because 'No ModuleVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.ModuleVersion + RequirementsVersion = $requirementsVersion + Constraint = 'LessOrEqual' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue } } } } +Describe 'Git tagging' -Skip { + BeforeAll { + $gitTagVersion = $null + + if ($git = Get-Command -Name 'git' -CommandType 'Application' -ErrorAction 'SilentlyContinue') { + $thisCommit = & $git log --decorate --oneline HEAD~1..HEAD + if ($thisCommit -match 'tag:\s*(\d+(?:\.\d+)*)') { $gitTagVersion = $matches[1] } + } + } + + It 'Is tagged with a valid version' { + $gitTagVersion | Should -Not -BeNullOrEmpty + $gitTagVersion -as [Version] | Should -Not -BeNullOrEmpty + } + + It 'Matches manifest version' { + $manifestData.Version -as [Version] | Should -Be ( $gitTagVersion -as [Version]) + } +} diff --git a/tests/ManifestHelpers.psm1 b/tests/ManifestHelpers.psm1 new file mode 100644 index 0000000..533824b --- /dev/null +++ b/tests/ManifestHelpers.psm1 @@ -0,0 +1,387 @@ +<# + This module provides helper functions for validating module dependency versions + using Semantic Versioning (SemVer) conventions, including prerelease version support. +#> + +function Split-SemVerString { + <# + .SYNOPSIS + Splits a version string into version and prerelease components. + + .DESCRIPTION + Parses a SemVer-formatted version string (e.g., "1.2.3-beta.1") into separate + version and prerelease components. This enables proper comparison of prerelease + versions using SemVer 2.0.0 specification rules. + + .PARAMETER VersionString + The version string to parse. Can be in the format "1.2.3" or "1.2.3-prerelease". + + .OUTPUTS + [hashtable] + Returns a hashtable with two keys: + - Version: The numeric version portion (e.g., "1.2.3") + - Prerelease: The prerelease identifier (e.g., "beta.1") or $null if none + + .EXAMPLE + Split-SemVerString -VersionString "1.2.3-beta.1" + + Returns: @{ Version = "1.2.3"; Prerelease = "beta.1" } + + .EXAMPLE + Split-SemVerString -VersionString "2.0.0" + + Returns: @{ Version = "2.0.0"; Prerelease = $null } + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$VersionString + ) + + # Strip build metadata per SemVer 2.0.0 — it does not affect precedence and is + # not valid for [System.Version], so it must be removed before further parsing. + $coreVersion = ($VersionString -split '\+', 2)[0] + $parts = $coreVersion -split '-', 2 + return @{ + Version = $parts[0] + Prerelease = if ($parts.Length -gt 1) { $parts[1] } else { $null } + } +} + +function Compare-SemVerPrerelease { + <# + .SYNOPSIS + Compares two SemVer prerelease identifiers according to SemVer 2.0.0 specification. + + .DESCRIPTION + Implements SemVer 2.0.0 prerelease comparison rules (section 11.4): + - Compares dot-separated identifiers from left to right + - Numeric identifiers are compared as integers + - Alphanumeric identifiers are compared lexically (ASCII sort) + - Numeric identifiers always have lower precedence than alphanumeric + - More identifiers > fewer identifiers (if all preceding are equal) + + .PARAMETER FirstPrerelease + The first prerelease identifier (e.g., "alpha.1"). Must not be null. + + .PARAMETER SecondPrerelease + The second prerelease identifier (e.g., "beta.2"). Must not be null. + + .OUTPUTS + [int] + Returns -1 if first < second, 0 if equal, 1 if first > second. + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "alpha.1" -SecondPrerelease "alpha.2" + Returns: -1 (alpha.1 < alpha.2) + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "beta.11" -SecondPrerelease "beta.2" + Returns: 1 (11 > 2 numerically) + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "1" -SecondPrerelease "alpha" + Returns: -1 (numeric < alphanumeric) + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$FirstPrerelease, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$SecondPrerelease + ) + + $firstIdentifiers = $FirstPrerelease -split '\.' + $secondIdentifiers = $SecondPrerelease -split '\.' + + $maxLength = [Math]::Max($firstIdentifiers.Length, $secondIdentifiers.Length) + + for ($i = 0; $i -lt $maxLength; $i++) { + # If first has fewer identifiers, it has lower precedence + if ($i -ge $firstIdentifiers.Length) { + return -1 + } + + # If second has fewer identifiers, it has lower precedence + if ($i -ge $secondIdentifiers.Length) { + return 1 + } + + $firstId = $firstIdentifiers[$i] + $secondId = $secondIdentifiers[$i] + + # Check if identifiers are numeric (consist only of digits) + $firstIsNumeric = $firstId -match '^\d+$' + $secondIsNumeric = $secondId -match '^\d+$' + + if ($firstIsNumeric -and $secondIsNumeric) { + # SemVer 2.0.0 permits arbitrarily large numeric identifiers — use BigInteger + # rather than [long] to avoid overflow. [bigint] is a PS 7+ accelerator only, + # so spell out the full type for Windows PowerShell 5.1 compatibility. + $firstNum = [System.Numerics.BigInteger]$firstId + $secondNum = [System.Numerics.BigInteger]$secondId + + if ($firstNum -lt $secondNum) { + return -1 + } + elseif ($firstNum -gt $secondNum) { + return 1 + } + # Equal, continue to next identifier + } + elseif ($firstIsNumeric) { + # First is numeric, second is alphanumeric: numeric < alphanumeric + return -1 + } + elseif ($secondIsNumeric) { + # First is alphanumeric, second is numeric: alphanumeric > numeric + return 1 + } + else { + # Both alphanumeric: compare lexically + $comparison = [string]::Compare($firstId, $secondId, [System.StringComparison]::Ordinal) + if ($comparison -ne 0) { + return [Math]::Sign($comparison) + } + # Equal, continue to next identifier + } + } + + # All identifiers are equal + return 0 +} + +function Test-VersionComparison { + <# + .SYNOPSIS + Compares two SemVer versions according to SemVer 2.0.0 specification. + + .DESCRIPTION + Compares two semantic versions, including their prerelease components, following + SemVer 2.0.0 rules. Returns $true if the first version is newer than the second version. + + Comparison logic: + 1. Compare base versions (major.minor.patch) numerically + 2. If base versions equal, apply prerelease precedence rules: + - Version without prerelease > version with prerelease + - Compare prerelease identifiers using SemVer rules + + .PARAMETER FirstVersion + The numeric version portion of the first version (e.g., "1.2.3"). + + .PARAMETER FirstPrerelease + The prerelease identifier of the first version (e.g., "beta.1") or $null if none. + + .PARAMETER SecondVersion + The numeric version portion of the second version (e.g., "1.2.2"). + + .PARAMETER SecondPrerelease + The prerelease identifier of the second version (e.g., "alpha.5") or $null if none. + + .OUTPUTS + [bool] + Returns $true if the first version is newer than the second version, $false otherwise. + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease "beta.1" -SecondVersion "1.2.3" -SecondPrerelease "alpha.1" + + Returns: $true (beta.1 is newer than alpha.1) + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease $null -SecondVersion "1.2.2" -SecondPrerelease $null + + Returns: $true (1.2.3 is newer than 1.2.2) + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease $null -SecondVersion "1.2.3" -SecondPrerelease "beta.1" + + Returns: $true (1.2.3 > 1.2.3-beta.1 per SemVer spec) + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$FirstVersion, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$FirstPrerelease, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$SecondVersion, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$SecondPrerelease + ) + + # Normalize versions to ensure consistent component count (fixes .NET Version comparison quirks) + # .NET treats "1.0.0" (Revision=-1) and "1.0.0.0" (Revision=0) as different, so normalize both to 4 components + $normalizedFirst = $FirstVersion + $firstComponents = $normalizedFirst.Split('.') + if ($firstComponents.Count -gt 4) { + throw "Version string '$FirstVersion' has too many components. .NET Version supports maximum 4 components (Major.Minor.Build.Revision)." + } + for ($c = $firstComponents.Count; $c -lt 4; $c++) { $normalizedFirst += '.0' } + + $normalizedSecond = $SecondVersion + $secondComponents = $normalizedSecond.Split('.') + if ($secondComponents.Count -gt 4) { + throw "Version string '$SecondVersion' has too many components. .NET Version supports maximum 4 components (Major.Minor.Build.Revision)." + } + for ($c = $secondComponents.Count; $c -lt 4; $c++) { $normalizedSecond += '.0' } + + # Compare base versions using .NET Version type + $firstVer = [version]$normalizedFirst + $secondVer = [version]$normalizedSecond + + $versionComparison = $firstVer.CompareTo($secondVer) + + if ($versionComparison -ne 0) { + # Base versions differ: return based on numeric comparison + return $versionComparison -gt 0 + } + + # Base versions are equal, apply prerelease precedence rules + $firstHasPrerelease = -not [string]::IsNullOrEmpty($FirstPrerelease) + $secondHasPrerelease = -not [string]::IsNullOrEmpty($SecondPrerelease) + + if (-not $firstHasPrerelease -and -not $secondHasPrerelease) { + # Both are release versions: equal + return $false + } + + if (-not $firstHasPrerelease) { + # First is release, second is prerelease: release > prerelease + return $true + } + + if (-not $secondHasPrerelease) { + # First is prerelease, second is release: prerelease < release + return $false + } + + # Both have prerelease: compare using SemVer prerelease rules + $prereleaseComparison = Compare-SemVerPrerelease -FirstPrerelease $FirstPrerelease -SecondPrerelease $SecondPrerelease + return $prereleaseComparison -gt 0 +} + +function Test-VersionConstraint { + <# + .SYNOPSIS + Tests whether a manifest version satisfies a version constraint. + + .DESCRIPTION + Validates that a module manifest version meets a specific constraint relative to + a requirements version. Supports three constraint types: + - Equal: Versions must be exactly the same (for RequiredVersion) + - GreaterOrEqual: Manifest version must be >= requirements (for MaximumVersion) + - LessOrEqual: Manifest version must be <= requirements (for ModuleVersion/minimum) + + .PARAMETER ManifestVersion + The version from the module manifest (e.g., "1.2.3-beta.1"). + + .PARAMETER RequirementsVersion + The version from requirements.psd1 (e.g., "1.2.3"). + + .PARAMETER Constraint + The type of constraint to validate. Valid values are: + - Equal: Versions must match exactly + - GreaterOrEqual: ManifestVersion >= RequirementsVersion + - LessOrEqual: ManifestVersion <= RequirementsVersion + + .OUTPUTS + [bool] + Returns $true if the constraint is satisfied, $false otherwise. + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.2.3" -RequirementsVersion "1.2.3" -Constraint "Equal" + + Returns: $true (versions match exactly) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "2.0.0" -RequirementsVersion "1.5.0" -Constraint "GreaterOrEqual" + + Returns: $true (2.0.0 >= 1.5.0) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.0.0" -RequirementsVersion "1.5.0" -Constraint "LessOrEqual" + + Returns: $true (1.0.0 <= 1.5.0) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.2.3-beta.1" -RequirementsVersion "1.2.3-alpha.5" -Constraint "GreaterOrEqual" + + Returns: $true (beta.1 is newer than alpha.5) + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ManifestVersion, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$RequirementsVersion, + + [Parameter(Mandatory = $true)] + [ValidateSet('Equal', 'GreaterOrEqual', 'LessOrEqual')] + [string]$Constraint + ) + + # Validate input versions are not empty + if ([string]::IsNullOrWhiteSpace($ManifestVersion)) { + throw 'ManifestVersion cannot be empty or whitespace' + } + if ([string]::IsNullOrWhiteSpace($RequirementsVersion)) { + throw 'RequirementsVersion cannot be empty or whitespace' + } + + $manifestParts = Split-SemVerString -VersionString $ManifestVersion + $requirementsParts = Split-SemVerString -VersionString $RequirementsVersion + + $comparisonParameters = @{ + FirstVersion = $requirementsParts.Version + FirstPrerelease = $requirementsParts.Prerelease + SecondVersion = $manifestParts.Version + SecondPrerelease = $manifestParts.Prerelease + } + $requirementsIsNewer = Test-VersionComparison @comparisonParameters + + $reversedComparisonParameters = @{ + FirstVersion = $manifestParts.Version + FirstPrerelease = $manifestParts.Prerelease + SecondVersion = $requirementsParts.Version + SecondPrerelease = $requirementsParts.Prerelease + } + $manifestIsNewer = Test-VersionComparison @reversedComparisonParameters + + switch ($Constraint) { + 'Equal' { + # RequiredVersion must exactly match (neither version is newer than the other) + return (-not $requirementsIsNewer) -and (-not $manifestIsNewer) + } + 'GreaterOrEqual' { + # MaximumVersion must be >= requirements (requirements not newer, or equal) + return (-not $requirementsIsNewer) + } + 'LessOrEqual' { + # ModuleVersion must be <= requirements (manifest not newer than requirements) + return (-not $manifestIsNewer) + } + default { + throw "Unsupported constraint: '$Constraint'" + } + } +} + +Export-ModuleMember -Function 'Test-VersionConstraint' From c8c7af093c0b29288fa656ced297ed2c048d019e Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 27 May 2026 01:20:20 -0400 Subject: [PATCH 2/2] test(manifest): parse changelog version with Select-String instead of foreach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the foreach-over-Get-Content changelog-version parse with Select-String, which returns the first matching line's named capture group directly — no loop and no break. The foreach existed only to avoid the canonical template's unreliable `ForEach-Object { ... break }`; Select-String removes the need for either form. Mirrors the upstream fix in tablackburn/PowerShellModuleTemplate#37. Build + Pester pass locally: 786 passed, 0 failed, 23 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Manifest.tests.ps1 | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index d790c27..e60cdb5 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -154,16 +154,12 @@ BeforeAll { $requirements = Import-PowerShellDataFile $requirementsPath -ErrorAction 'Stop' # Parse the version from the changelog - # Note: Use foreach statement (not ForEach-Object) because 'break' doesn't work reliably in ForEach-Object $changelogPath = Join-Path -Path $Env:BHProjectPath -ChildPath 'CHANGELOG.md' $changelogVersionPattern = '^##\s\\?\[(?(\d+\.){1,3}\d+)\\?\]' # Matches on a line that starts with '## [Version]' or '## \[Version\]' - $changelogVersion = $null - foreach ($line in Get-Content $changelogPath) { - if ($line -match $changelogVersionPattern) { - $changelogVersion = $matches.Version - break - } - } + # Select-String returns the first matching line's named capture directly — no loop and no + # 'break' (which is unreliable inside ForEach-Object, since a pipeline is not a loop). + $changelogVersion = (Select-String -Path $changelogPath -Pattern $changelogVersionPattern | + Select-Object -First 1).Matches[0].Groups['Version'].Value } Describe 'Module manifest' {