From e3c22f7d663b2b9e0dfeee5193744634ed2912e6 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Mon, 15 Jun 2026 09:56:35 -0700 Subject: [PATCH 1/3] feat: support NuGet version ranges for gallery dependency types (#65, #91) Carry version ranges in the existing Version field using NuGet range syntax (e.g. '[2.2.3,3.0)', '[2.0,)', '(,3.0)') for PSGalleryModule, PSResourceGet, and PSGalleryNuget. A bare version still means an exact version; a range installs the highest available version that satisfies it. Add three private helpers: Compare-Version (IComparable ordering primitive that replaces the duplicated TryParse blocks), ConvertFrom-VersionRange (parser), and Test-VersionInRange (the shared satisfaction predicate). Test-VersionEquality is reimplemented on Compare-Version. PSResourceGet passes the range straight to Install-PSResource; the other two resolve the range to an exact version (find -> filter -> install) since their installers cannot express exclusive bounds. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 + CONTEXT.md | 5 + PSDepend/PSDependScripts/PSGalleryModule.ps1 | 63 ++++++---- PSDepend/PSDependScripts/PSGalleryNuget.ps1 | 51 +++++---- PSDepend/PSDependScripts/PSResourceGet.ps1 | 25 +--- PSDepend/Private/Compare-Version.ps1 | 77 +++++++++++++ PSDepend/Private/ConvertFrom-VersionRange.ps1 | 108 ++++++++++++++++++ PSDepend/Private/Test-VersionEquality.ps1 | 42 +------ PSDepend/Private/Test-VersionInRange.ps1 | 73 ++++++++++++ Tests/Compare-Version.Tests.ps1 | 81 +++++++++++++ Tests/ConvertFrom-VersionRange.Tests.ps1 | 103 +++++++++++++++++ Tests/PSGalleryModule.Type.Tests.ps1 | 45 ++++++++ Tests/PSGalleryNuget.Type.Tests.ps1 | 35 ++++++ Tests/PSResourceGet.Type.Tests.ps1 | 23 ++++ Tests/Test-VersionInRange.Tests.ps1 | 105 +++++++++++++++++ adr/0001-nuget-version-ranges.md | 43 +++++++ 16 files changed, 779 insertions(+), 105 deletions(-) create mode 100644 PSDepend/Private/Compare-Version.ps1 create mode 100644 PSDepend/Private/ConvertFrom-VersionRange.ps1 create mode 100644 PSDepend/Private/Test-VersionInRange.ps1 create mode 100644 Tests/Compare-Version.Tests.ps1 create mode 100644 Tests/ConvertFrom-VersionRange.Tests.ps1 create mode 100644 Tests/Test-VersionInRange.Tests.ps1 create mode 100644 adr/0001-nuget-version-ranges.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ee827..37647b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Version ranges in the `Version` field using NuGet range syntax (e.g. + `'[2.2.3,3.0)'`, `'[2.0,)'`, `'(,3.0)'`) for the `PSGalleryModule`, + `PSResourceGet`, and `PSGalleryNuget` dependency types. A bare version + (e.g. `'3.2.1'`) still means that exact version; a range installs the + highest available version that satisfies it (#65, #91). - `FileDownload` is now supported on all platforms (`windows`, `core`, `macos`, `linux`); there was no Windows-only code blocking this (#98). - `FileDownload` relative `Target` paths are now rooted against `$PWD` diff --git a/CONTEXT.md b/CONTEXT.md index 841d6b3..c3de87c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -40,6 +40,10 @@ _Avoid_: dependency (to avoid confusion with the Dependency concept), requiremen A label on a Dependency that controls inclusion when `Invoke-PSDepend` is called with `-Tags`. _Avoid_: filter, category, label +**VersionRange**: +A constraint on which versions of a Dependency satisfy it, expressed in NuGet range syntax (e.g. `[2.2.3,3.0)`, `[2.0,)`) inside the Version field. A bare version (`3.2.1`) is not a range — it means exactly that version. +_Avoid_: version spec, version constraint, MinimumVersion/MaximumVersion + ## Relationships - A **DependencyFile** contains one or more **Dependencies** and at most one **PSDependOptions** block @@ -48,6 +52,7 @@ _Avoid_: filter, category, label - A **Dependency** may carry zero or more **Tags** - A **DependencyScript** receives a **Dependency** and a set of **PSDependAction** flags on each invocation - **Target** is a field on a **Dependency** interpreted differently by each **DependencyScript** +- A **Dependency**'s Version field carries either an exact version or a **VersionRange**; each gallery **DependencyScript** resolves a **VersionRange** to a concrete version to install ## Example dialogue diff --git a/PSDepend/PSDependScripts/PSGalleryModule.ps1 b/PSDepend/PSDependScripts/PSGalleryModule.ps1 index c6dbee7..dee4808 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -8,6 +8,7 @@ Relevant Dependency metadata: Name: The name for this module Version: Used to identify existing installs meeting this criteria, and as RequiredVersion for installation. Defaults to 'latest' + Also accepts a NuGet version range (e.g. '[2.2.3,3.0)', '[2.0,)', '(,3.0)'). A bare version (e.g. '3.2.1') still means that exact version. When a range is given, the highest available version that satisfies it is installed. Target: Used as 'Scope' for Install-Module. If this is a path, we use Save-Module with this path. On reruns, PSDepend checks existing modules first and skips reinstalling when the requested version is already present. Defaults to 'AllUsers' AddToPath: If target is used as a path, prepend that path to ENV:PSModulePath Credential: The username and password used to authenticate against the private repository @@ -90,6 +91,13 @@ } } # Install the latest version of PowerCLI, allowing for prerelease + + .EXAMPLE + @{ + BuildHelpers = '[2.0.0,3.0.0)' + } + + # Install the highest BuildHelpers version that is >= 2.0.0 and < 3.0.0 (NuGet range syntax) #> [CmdletBinding()] param( @@ -192,8 +200,14 @@ if ($Repository) { $params.Add('Repository', $Repository) } +# Exact versions map straight to RequiredVersion. Ranges have no Install-Module +# parameter, so they are resolved to a concrete version just before install. +$versionRange = $null if ($Version -and $Version -ne 'latest') { - $Params.add('RequiredVersion', $Version) + $versionRange = ConvertFrom-VersionRange -Version $Version + if ($versionRange -and $versionRange.IsExact) { + $Params.add('RequiredVersion', $versionRange.Exact) + } } if ($Credential) { @@ -230,7 +244,7 @@ if ($Existing) { Write-Verbose "Found existing module [$Name]" if ($Version -and $Version -ne 'latest') { - $matchedInstall = $Existing | Where-Object { Test-VersionEquality $Version $_.Version.ToString() } | Select-Object -First 1 + $matchedInstall = $Existing | Where-Object { Test-VersionInRange -Version $_.Version.ToString() -Required $Version } | Select-Object -First 1 if ($matchedInstall) { Write-Verbose "You have the requested version [$Version] of [$Name]" Import-PSDependModule -Name $ModuleName -Action $PSDependAction -Version $matchedInstall.Version @@ -253,25 +267,7 @@ if ($Existing) { } $GalleryVersion = Find-Module @FindModuleParams | Measure-Object -Property Version -Maximum | Select-Object -ExpandProperty Maximum - [System.Version]$parsedExistingVersion = $null - [System.Version]$parsedGalleryVersion = $null - [System.Management.Automation.SemanticVersion]$parsedExistingSemanticVersion = $null - [System.Management.Automation.SemanticVersion]$parsedGallerySemanticVersion = $null - $isGalleryVersionLessEquals = if ( - [System.Management.Automation.SemanticVersion]::TryParse([string]$ExistingVersion, [ref]$parsedExistingSemanticVersion) -and - [System.Management.Automation.SemanticVersion]::TryParse([string]$GalleryVersion, [ref]$parsedGallerySemanticVersion) - ) { - $parsedGallerySemanticVersion -le $parsedExistingSemanticVersion - } - elseif ( - [System.Version]::TryParse([string]$ExistingVersion, [ref]$parsedExistingVersion) -and - [System.Version]::TryParse([string]$GalleryVersion, [ref]$parsedGalleryVersion) - ) { - $parsedGalleryVersion -le $parsedExistingVersion - } - else { - $false - } + $isGalleryVersionLessEquals = (Compare-Version -ReferenceVersion ([string]$GalleryVersion) -DifferenceVersion ([string]$ExistingVersion)) -le 0 # latest, and we have latest if ( $Version -and ($Version -eq 'latest' -or $Version -eq '') -and $isGalleryVersionLessEquals) { @@ -292,6 +288,31 @@ if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { return $False } +# Resolve a version range to the highest available version that satisfies it, +# then install that exact version (Install-Module has no native range parameter). +if ($versionRange -and -not $versionRange.IsExact -and $PSDependAction -contains 'Install') { + $resolveParams = @{ Name = $Name } + if ($Repository) { $resolveParams.Add('Repository', $Repository) } + if ($Credential) { $resolveParams.Add('Credential', $Credential) } + if ($AllowPrerelease) { $resolveParams.Add('AllowPrerelease', $AllowPrerelease) } + + $resolvedVersion = $null + foreach ($candidate in (Find-Module @resolveParams -AllVersions)) { + $candidateVersion = $candidate.Version.ToString() + if ((Test-VersionInRange -Version $candidateVersion -Required $Version) -and + ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidateVersion -DifferenceVersion $resolvedVersion) -gt 0)) { + $resolvedVersion = $candidateVersion + } + } + + if (-not $resolvedVersion) { + Write-Error "No version of [$Name] in repository [$Repository] satisfies range [$Version]" + return + } + Write-Verbose "Resolved range [$Version] to version [$resolvedVersion] for [$Name]" + $params['RequiredVersion'] = $resolvedVersion +} + if ($PSDependAction -contains 'Install') { if ('AllUsers', 'CurrentUser' -contains $Scope) { Write-Verbose "Installing [$Name] with scope [$Scope]" diff --git a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 index f9c6ece..d684392 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -10,6 +10,7 @@ Relevant Dependency metadata: Name: The name for this module Version: Used to identify existing installs meeting this criteria, and as RequiredVersion for installation. Defaults to 'latest' + Also accepts a NuGet version range (e.g. '[2.2.3,3.0)', '[2.0,)', '(,3.0)'). A bare version (e.g. '0.1.19') still means that exact version. When a range is given, the highest available version that satisfies it is installed. Source: Source Uri for Nuget. Defaults to https://www.powershellgallery.com/api/v2/ Target: Required path to save this module. No Default Example: To install PSDeploy to C:\temp\PSDeploy, I would specify C:\temp @@ -123,9 +124,9 @@ if (Test-Path $ModulePath) { $ExistingVersion = $ManifestData.ModuleVersion $GetGalleryVersion = { (Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential -IsLatest).Version } - # Version string, and equal to current + # Version string (exact or range), and the installed version satisfies it if ($Version -and $Version -ne 'latest') { - if (Test-VersionEquality $Version $ExistingVersion) { + if (Test-VersionInRange -Version $ExistingVersion -Required $Version) { Write-Verbose "You have the requested version [$Version] of [$Name]" # Conditional import Import-PSDependModule -Name $ModulePath -Action $PSDependAction -Version $ExistingVersion @@ -139,25 +140,7 @@ if (Test-Path $ModulePath) { # latest, and we have latest if ($Version -and ($Version -eq 'latest' -or $Version -like '')) { $GalleryVersion = & $GetGalleryVersion - [System.Version]$parsedExistingVersion = $null - [System.Version]$parsedGalleryVersion = $null - [System.Management.Automation.SemanticVersion]$parsedExistingSemanticVersion = $null - [System.Management.Automation.SemanticVersion]$parsedGallerySemanticVersion = $null - $isGalleryVersionLessEquals = if ( - [System.Management.Automation.SemanticVersion]::TryParse([string]$ExistingVersion, [ref]$parsedExistingSemanticVersion) -and - [System.Management.Automation.SemanticVersion]::TryParse([string]$GalleryVersion, [ref]$parsedGallerySemanticVersion) - ) { - $parsedGallerySemanticVersion -le $parsedExistingSemanticVersion - } - elseif ( - [System.Version]::TryParse([string]$ExistingVersion, [ref]$parsedExistingVersion) -and - [System.Version]::TryParse([string]$GalleryVersion, [ref]$parsedGalleryVersion) - ) { - $parsedGalleryVersion -le $parsedExistingVersion - } - else { - $false - } + $isGalleryVersionLessEquals = (Compare-Version -ReferenceVersion ([string]$GalleryVersion) -DifferenceVersion ([string]$ExistingVersion)) -le 0 if ($isGalleryVersionLessEquals) { Write-Verbose "You have the latest version of [$Name], with installed version [$ExistingVersion] and PSGallery version [$GalleryVersion]" @@ -190,6 +173,28 @@ if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { return $False } +# Resolve a version range to the highest available version that satisfies it; +# nuget.exe -version takes an exact version, not a range. +$installVersion = $Version +if ($Version -and $Version -notlike 'latest') { + $range = ConvertFrom-VersionRange -Version $Version + if ($range -and -not $range.IsExact) { + $resolvedVersion = $null + foreach ($candidate in (Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential)) { + if ((Test-VersionInRange -Version $candidate.Version -Required $Version) -and + ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidate.Version -DifferenceVersion $resolvedVersion) -gt 0)) { + $resolvedVersion = $candidate.Version + } + } + if (-not $resolvedVersion) { + Write-Error "No version of [$Name] at source [$Source] satisfies range [$Version]" + return + } + Write-Verbose "Resolved range [$Version] to version [$resolvedVersion] for [$Name]" + $installVersion = $resolvedVersion + } +} + if ($PSDependAction -contains 'Install') { $TargetExists = Test-Path $Target -PathType Container @@ -200,7 +205,7 @@ if ($PSDependAction -contains 'Install') { $Null = New-Item -ItemType Directory -Path $Target -Force -ErrorAction SilentlyContinue } if ($Version -and $Version -notlike 'latest') { - $NugetParams += '-version', $Version + $NugetParams += '-version', $installVersion } $NugetParams = 'install', $Name + $NugetParams @@ -209,6 +214,6 @@ if ($PSDependAction -contains 'Install') { # Conditional import $importVs = if ($Version -and $Version -notlike 'latest') { - $Version + $installVersion } Import-PSDependModule -Name $ModulePath -Action $PSDependAction -Version $importVs diff --git a/PSDepend/PSDependScripts/PSResourceGet.ps1 b/PSDepend/PSDependScripts/PSResourceGet.ps1 index 0eec9d5..89ce4d4 100644 --- a/PSDepend/PSDependScripts/PSResourceGet.ps1 +++ b/PSDepend/PSDependScripts/PSResourceGet.ps1 @@ -256,10 +256,10 @@ if ($Existing) { $FindModuleParams.Add('Prerelease', $true) } - # Version string, and that version is already installed (may not be the maximum) + # Version string (exact or range), and a satisfying version is already installed $matchedExisting = if ($Version -and $Version -ne 'latest') { $Existing | Where-Object { - Test-VersionEquality -ReferenceVersion $_.Version -DifferenceVersion $Version + Test-VersionInRange -Version $_.Version -Required $Version } | Select-Object -First 1 } if ($matchedExisting) { @@ -273,26 +273,7 @@ if ($Existing) { } $GalleryVersion = Find-PSResource @FindModuleParams | Measure-Object -Property Version -Maximum | Select-Object -ExpandProperty Maximum - # Compare using SemanticVersion first (PSResourceGet uses SemVer); fall back to System.Version - [System.Version]$parsedVersion = $null - [System.Version]$parsedGalleryVersion = $null - [System.Management.Automation.SemanticVersion]$parsedSemanticVersion = $null - [System.Management.Automation.SemanticVersion]$parsedTempSemanticVersion = $null - $existingIsUpToDate = if ( - [System.Management.Automation.SemanticVersion]::TryParse([string]$ExistingVersion, [ref]$parsedSemanticVersion) -and - [System.Management.Automation.SemanticVersion]::TryParse([string]$GalleryVersion, [ref]$parsedTempSemanticVersion) - ) { - $parsedTempSemanticVersion -le $parsedSemanticVersion - } - elseif ( - [System.Version]::TryParse([string]$ExistingVersion, [ref]$parsedVersion) -and - [System.Version]::TryParse([string]$GalleryVersion, [ref]$parsedGalleryVersion) - ) { - $parsedGalleryVersion -le $parsedVersion - } - else { - $false - } + $existingIsUpToDate = (Compare-Version -ReferenceVersion ([string]$GalleryVersion) -DifferenceVersion ([string]$ExistingVersion)) -le 0 # latest, and we have latest if ($Version -and ($Version -eq 'latest' -or $Version -eq '') -and $existingIsUpToDate) { diff --git a/PSDepend/Private/Compare-Version.ps1 b/PSDepend/Private/Compare-Version.ps1 new file mode 100644 index 0000000..3b0a404 --- /dev/null +++ b/PSDepend/Private/Compare-Version.ps1 @@ -0,0 +1,77 @@ +function Compare-Version { + <# + .SYNOPSIS + Order two version strings, returning -1, 0, or 1. + + .DESCRIPTION + Coerce both version strings to a common comparable type and compare them via + [IComparable]. SemanticVersion is tried first so pre-release ordering is + honoured (e.g. 1.0.0-alpha sorts below 1.0.0); System.Version is the + fallback so four-part versions (1.2.3.4) still compare. Missing System.Version + components are normalised to 0 so 1.2.3 and 1.2.3.0 compare equal. If neither + type can parse both inputs, fall back to an ordinal string comparison. + + Both operands must coerce to the same type - a SemanticVersion cannot be + compared to a System.Version - so each branch requires both inputs to parse. + + .PARAMETER ReferenceVersion + The version on the left of the comparison. + + .PARAMETER DifferenceVersion + The version on the right of the comparison. + + .EXAMPLE + Compare-Version -ReferenceVersion '1.2.0' -DifferenceVersion '1.2.3' + + Returns -1 (1.2.0 is less than 1.2.3). + + .EXAMPLE + Compare-Version -ReferenceVersion '1.0.0' -DifferenceVersion '1.0.0-beta' + + Returns 1 (a release sorts above its pre-release). + #> + [CmdletBinding()] + [OutputType([int])] + param( + [string]$ReferenceVersion, + [string]$DifferenceVersion + ) + + # SemanticVersion first: it orders pre-release labels correctly. + [System.Management.Automation.SemanticVersion]$refSemVer = $null + [System.Management.Automation.SemanticVersion]$diffSemVer = $null + if ( + [System.Management.Automation.SemanticVersion]::TryParse($ReferenceVersion, [ref]$refSemVer) -and + [System.Management.Automation.SemanticVersion]::TryParse($DifferenceVersion, [ref]$diffSemVer) + ) { + return $refSemVer.CompareTo($diffSemVer) + } + + # System.Version fallback handles four-part versions SemVer rejects. + # Normalise absent components (-1) to 0 so 1.2.3 equals 1.2.3.0. + [System.Version]$refVer = $null + [System.Version]$diffVer = $null + if ( + [System.Version]::TryParse($ReferenceVersion, [ref]$refVer) -and + [System.Version]::TryParse($DifferenceVersion, [ref]$diffVer) + ) { + $refNormalised = [System.Version]::new( + [Math]::Max($refVer.Major, 0), + [Math]::Max($refVer.Minor, 0), + [Math]::Max($refVer.Build, 0), + [Math]::Max($refVer.Revision, 0) + ) + $diffNormalised = [System.Version]::new( + [Math]::Max($diffVer.Major, 0), + [Math]::Max($diffVer.Minor, 0), + [Math]::Max($diffVer.Build, 0), + [Math]::Max($diffVer.Revision, 0) + ) + return $refNormalised.CompareTo($diffNormalised) + } + + # Neither type parses both: ordinal string comparison, clamped to -1/0/1. + return [Math]::Sign( + [string]::Compare($ReferenceVersion, $DifferenceVersion, [System.StringComparison]::OrdinalIgnoreCase) + ) +} diff --git a/PSDepend/Private/ConvertFrom-VersionRange.ps1 b/PSDepend/Private/ConvertFrom-VersionRange.ps1 new file mode 100644 index 0000000..81d7686 --- /dev/null +++ b/PSDepend/Private/ConvertFrom-VersionRange.ps1 @@ -0,0 +1,108 @@ +function ConvertFrom-VersionRange { + <# + .SYNOPSIS + Parse a NuGet version range string into a structured bounds object. + + .DESCRIPTION + PSDepend carries version ranges in the Version field using NuGet range + syntax. A string is only treated as a range when it contains a range + delimiter ('[', ']', '(', ')', ','); a bare version (e.g. 3.2.1) is returned + as an exact match so existing requirements files keep their meaning. + + Returns a [PSCustomObject] with these properties: + IsExact - $true when the request is a single exact version + Exact - the exact version string (only when IsExact) + Min - lower bound version string, or $null for no lower bound + Max - upper bound version string, or $null for no upper bound + MinInclusive - $true when the lower bound is inclusive ('[') + MaxInclusive - $true when the upper bound is inclusive (']') + + Brackets denote inclusive bounds, parentheses exclusive. A bracketed single + value ([1.0]) is an exact match. An empty side means that bound is open. + Malformed ranges produce a non-terminating error and return nothing. + + .PARAMETER Version + The version or NuGet range string to parse. + + .EXAMPLE + ConvertFrom-VersionRange -Version '3.2.1' + + Returns an object with IsExact = $true and Exact = '3.2.1'. + + .EXAMPLE + ConvertFrom-VersionRange -Version '[2.2.3,3.0)' + + Returns Min = '2.2.3' (inclusive), Max = '3.0' (exclusive). + + .EXAMPLE + ConvertFrom-VersionRange -Version '(,3.0)' + + Returns Min = $null, Max = '3.0' (exclusive) - an upper-bound-only range. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [string]$Version + ) + + $exactResult = { + param($Value) + [PSCustomObject]@{ + IsExact = $true + Exact = $Value + Min = $null + Max = $null + MinInclusive = $true + MaxInclusive = $true + } + } + + # No range delimiter: a bare version is an exact match. + if ($Version -notmatch '[\[\](),]') { + return & $exactResult $Version + } + + # Bracketed/parenthesised range: first and last char carry inclusivity. + $open = $Version[0] + $close = $Version[-1] + if ($open -notin '[', '(' -or $close -notin ']', ')') { + Write-Error "Invalid version range [$Version]: must start with '[' or '(' and end with ']' or ')'." + return + } + + $minInclusive = $open -eq '[' + $maxInclusive = $close -eq ']' + $inner = $Version.Substring(1, $Version.Length - 2) + + # A single value with no comma (e.g. [1.0]) is an exact match. + if ($inner -notmatch ',') { + $single = $inner.Trim() + if ([string]::IsNullOrEmpty($single)) { + Write-Error "Invalid version range [$Version]: no version specified." + return + } + if (-not $minInclusive -or -not $maxInclusive) { + Write-Error "Invalid version range [$Version]: a single version must be bracketed as [version]." + return + } + return & $exactResult $single + } + + $parts = $inner -split ',', 2 + $min = $parts[0].Trim() + $max = $parts[1].Trim() + + if ([string]::IsNullOrEmpty($min) -and [string]::IsNullOrEmpty($max)) { + Write-Error "Invalid version range [$Version]: at least one bound is required." + return + } + + [PSCustomObject]@{ + IsExact = $false + Exact = $null + Min = if ([string]::IsNullOrEmpty($min)) { $null } else { $min } + Max = if ([string]::IsNullOrEmpty($max)) { $null } else { $max } + MinInclusive = $minInclusive + MaxInclusive = $maxInclusive + } +} diff --git a/PSDepend/Private/Test-VersionEquality.ps1 b/PSDepend/Private/Test-VersionEquality.ps1 index 396cccd..52fe904 100644 --- a/PSDepend/Private/Test-VersionEquality.ps1 +++ b/PSDepend/Private/Test-VersionEquality.ps1 @@ -45,43 +45,7 @@ return $false } - # Parsing requires existing references to exist, so we create them. - [System.Version]$parsedRef = $null - [System.Version]$parsedDiff = $null - - # Check if we can parse both versions as System.Version. If we can, we - # compare them using individual components. - # Because System.Version treats missing components as -1, we use Math.Max to - # treat them as 0 for comparison purposes (e.g. 1.2 is treated as 1.2.0.0). - if ([System.Version]::TryParse($ReferenceVersion, [ref]$parsedRef) -and - [System.Version]::TryParse($DifferenceVersion, [ref]$parsedDiff) - ) { - return ( - $parsedRef.Major -eq $parsedDiff.Major -and - $parsedRef.Minor -eq $parsedDiff.Minor -and - [Math]::Max($parsedRef.Build, 0) -eq [Math]::Max($parsedDiff.Build, 0) -and - [Math]::Max($parsedRef.Revision, 0) -eq [Math]::Max($parsedDiff.Revision, 0) - ) - } - - # If they can't be parsed as System.Version, we attempt to parse them as - # SemanticVersion, which can handle prerelease and build metadata. - [System.Management.Automation.SemanticVersion]$parsedRefSemVer = $null - [System.Management.Automation.SemanticVersion]$parsedDiffSemVer = $null - - if ( - [System.Management.Automation.SemanticVersion]::TryParse( - $ReferenceVersion, [ref]$parsedRefSemVer - ) -and - [System.Management.Automation.SemanticVersion]::TryParse( - $DifferenceVersion, [ref]$parsedDiffSemVer - ) - ) { - return $parsedRefSemVer -eq $parsedDiffSemVer - } - - # TODO: Investigate if we want to add additional parsing logic here for - # other version formats (e.g. date or commit based versions) - - return $ReferenceVersion -eq $DifferenceVersion + # Equality is the zero case of the shared ordering primitive. Compare-Version + # handles SemanticVersion, normalised System.Version, and string fallback. + return (Compare-Version -ReferenceVersion $ReferenceVersion -DifferenceVersion $DifferenceVersion) -eq 0 } diff --git a/PSDepend/Private/Test-VersionInRange.ps1 b/PSDepend/Private/Test-VersionInRange.ps1 new file mode 100644 index 0000000..89edc70 --- /dev/null +++ b/PSDepend/Private/Test-VersionInRange.ps1 @@ -0,0 +1,73 @@ +function Test-VersionInRange { + <# + .SYNOPSIS + Test whether an installed version satisfies a requested version or range. + + .DESCRIPTION + The single entry point gallery DependencyScripts use to decide whether an + already-installed version satisfies the request. The request may be an exact + version (delegated to Test-VersionEquality) or a NuGet range (parsed by + ConvertFrom-VersionRange and compared bound-by-bound with Compare-Version). + + Range semantics live here so every DependencyType evaluates a range the same + way, regardless of what its installer accepts natively. + + .PARAMETER Version + The concrete installed version to test. + + .PARAMETER Required + The requested version or NuGet range string. + + .EXAMPLE + Test-VersionInRange -Version '2.5.0' -Required '[2.2.3,3.0)' + + Returns $true (2.5.0 falls within the range). + + .EXAMPLE + Test-VersionInRange -Version '3.0.0' -Required '[2.2.3,3.0)' + + Returns $false (the upper bound is exclusive). + + .EXAMPLE + Test-VersionInRange -Version '3.2.1' -Required '3.2.1' + + Returns $true (exact match). + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [string]$Version, + [string]$Required + ) + + if ([string]::IsNullOrEmpty($Version) -or [string]::IsNullOrEmpty($Required)) { + return $false + } + + $range = ConvertFrom-VersionRange -Version $Required -ErrorAction SilentlyContinue + if (-not $range) { + return $false + } + + if ($range.IsExact) { + return Test-VersionEquality -ReferenceVersion $Version -DifferenceVersion $range.Exact + } + + if ($null -ne $range.Min) { + $lower = Compare-Version -ReferenceVersion $Version -DifferenceVersion $range.Min + $satisfiesMin = if ($range.MinInclusive) { $lower -ge 0 } else { $lower -gt 0 } + if (-not $satisfiesMin) { + return $false + } + } + + if ($null -ne $range.Max) { + $upper = Compare-Version -ReferenceVersion $Version -DifferenceVersion $range.Max + $satisfiesMax = if ($range.MaxInclusive) { $upper -le 0 } else { $upper -lt 0 } + if (-not $satisfiesMax) { + return $false + } + } + + return $true +} diff --git a/Tests/Compare-Version.Tests.ps1 b/Tests/Compare-Version.Tests.ps1 new file mode 100644 index 0000000..4e9626e --- /dev/null +++ b/Tests/Compare-Version.Tests.ps1 @@ -0,0 +1,81 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'Compare-Version' { + + Context 'SemanticVersion ordering' { + + It 'Returns 0 for equal three-part versions' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.3' -DifferenceVersion '1.2.3' + } | Should -Be 0 + } + + It 'Returns -1 when reference is lower' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.0' -DifferenceVersion '1.2.3' + } | Should -Be -1 + } + + It 'Returns 1 when reference is higher' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '2.0.0' -DifferenceVersion '1.9.9' + } | Should -Be 1 + } + + It 'Orders a pre-release below its release' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.0.0-alpha' -DifferenceVersion '1.0.0' + } | Should -Be -1 + } + + It 'Orders pre-release labels lexically' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '2.0.0-beta' -DifferenceVersion '2.0.0-alpha' + } | Should -Be 1 + } + } + + Context 'System.Version fallback and normalisation' { + + It 'Compares four-part versions SemVer rejects' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.3.4' -DifferenceVersion '1.2.3.5' + } | Should -Be -1 + } + + It 'Treats absent build/revision as zero' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '1.2.3.0' -DifferenceVersion '1.2.3' + } | Should -Be 0 + } + + It 'Distinguishes a non-zero revision from an absent one' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion '0.0.0.5' -DifferenceVersion '0.0.0' + } | Should -Be 1 + } + } + + Context 'String fallback' { + + It 'Returns 0 for identical unparseable strings' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion 'latest' -DifferenceVersion 'latest' + } | Should -Be 0 + } + + It 'Returns non-zero for different unparseable strings' { + InModuleScope PSDepend { + Compare-Version -ReferenceVersion 'latest' -DifferenceVersion 'stable' + } | Should -Not -Be 0 + } + } +} diff --git a/Tests/ConvertFrom-VersionRange.Tests.ps1 b/Tests/ConvertFrom-VersionRange.Tests.ps1 new file mode 100644 index 0000000..8e539d9 --- /dev/null +++ b/Tests/ConvertFrom-VersionRange.Tests.ps1 @@ -0,0 +1,103 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'ConvertFrom-VersionRange' { + + Context 'Exact versions (no delimiters)' { + + It 'Treats a bare version as exact' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '3.2.1' + $r.IsExact | Should -BeTrue + $r.Exact | Should -Be '3.2.1' + } + } + + It 'Treats a bracketed single version as exact' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[1.0]' + $r.IsExact | Should -BeTrue + $r.Exact | Should -Be '1.0' + } + } + } + + Context 'Bounded ranges' { + + It 'Parses an inclusive-lower, exclusive-upper range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[2.2.3,3.0)' + $r.IsExact | Should -BeFalse + $r.Min | Should -Be '2.2.3' + $r.Max | Should -Be '3.0' + $r.MinInclusive | Should -BeTrue + $r.MaxInclusive | Should -BeFalse + } + } + + It 'Parses an exclusive-lower, inclusive-upper range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '(1.0,2.0]' + $r.MinInclusive | Should -BeFalse + $r.MaxInclusive | Should -BeTrue + } + } + + It 'Tolerates whitespace around bounds' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[1.0, 2.0]' + $r.Min | Should -Be '1.0' + $r.Max | Should -Be '2.0' + } + } + } + + Context 'Open-ended ranges' { + + It 'Parses a minimum-only range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '[2.0,)' + $r.Min | Should -Be '2.0' + $r.Max | Should -BeNullOrEmpty + $r.MinInclusive | Should -BeTrue + } + } + + It 'Parses a maximum-only range' { + InModuleScope PSDepend { + $r = ConvertFrom-VersionRange -Version '(,3.0)' + $r.Min | Should -BeNullOrEmpty + $r.Max | Should -Be '3.0' + $r.MaxInclusive | Should -BeFalse + } + } + } + + Context 'Malformed ranges' { + + It 'Errors on a missing closing bracket' { + InModuleScope PSDepend { + ConvertFrom-VersionRange -Version '[1.0,2.0' -ErrorAction SilentlyContinue + } | Should -BeNullOrEmpty + } + + It 'Errors on an empty range' { + InModuleScope PSDepend { + ConvertFrom-VersionRange -Version '(,)' -ErrorAction SilentlyContinue + } | Should -BeNullOrEmpty + } + + It 'Errors on a parenthesised single version' { + InModuleScope PSDepend { + ConvertFrom-VersionRange -Version '(1.0)' -ErrorAction SilentlyContinue + } | Should -BeNullOrEmpty + } + } +} diff --git a/Tests/PSGalleryModule.Type.Tests.ps1 b/Tests/PSGalleryModule.Type.Tests.ps1 index 84a5abe..8b51826 100644 --- a/Tests/PSGalleryModule.Type.Tests.ps1 +++ b/Tests/PSGalleryModule.Type.Tests.ps1 @@ -204,4 +204,49 @@ Describe 'PSGalleryModule script' { -ParameterFilter { -not $PSBoundParameters.ContainsKey('Repository') } } } + + Context 'Version range resolution' { + It 'Resolves a range to the highest satisfying version and installs it exactly' { + InModuleScope PSDepend { + Mock Get-Module { } -ParameterFilter { $ListAvailable } + Mock Find-Module { + @( + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'1.9.0' } + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.5.0' } + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'3.0.0' } + ) + } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $RequiredVersion -eq '2.5.0' + } + } + + It 'Errors and skips install when no available version satisfies the range' { + InModuleScope PSDepend { + Mock Get-Module { } -ParameterFilter { $ListAvailable } + Mock Find-Module { @([PSCustomObject]@{ Name = 'TestModule'; Version = [version]'1.0.0' }) } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -ErrorAction SilentlyContinue + } + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + } + + It 'Skips install when an installed version already satisfies the range' { + InModuleScope PSDepend { + Mock Get-Module { [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.5.0' } } -ParameterFilter { $ListAvailable } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index c970c9e..0ced47a 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -92,4 +92,39 @@ Describe 'PSGalleryNuget script' { Should -Invoke -CommandName BootStrap-Nuget -ModuleName PSDepend -Times 0 } } + + Context 'Version range resolution' { + It 'Resolves a range to the highest satisfying version and passes it to nuget install' { + InModuleScope PSDepend { + Mock Find-NugetPackage { + @( + [PSCustomObject]@{ Version = '1.9.0' } + [PSCustomObject]@{ Version = '2.5.0' } + [PSCustomObject]@{ Version = '3.0.0' } + ) + } + } + $targetDir = (New-Item 'TestDrive:/psgnuget-range' -ItemType Directory -Force).FullName + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Target $targetDir -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 1 -ParameterFilter { + $i = [array]::IndexOf($Arguments, '-version') + $i -ge 0 -and $Arguments[$i + 1] -eq '2.5.0' + } + } + + It 'Errors and skips nuget install when no version satisfies the range' { + InModuleScope PSDepend { + Mock Find-NugetPackage { @([PSCustomObject]@{ Version = '1.0.0' }) } + } + $targetDir = (New-Item 'TestDrive:/psgnuget-range-none' -ItemType Directory -Force).FullName + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Target $targetDir -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -ErrorAction SilentlyContinue + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/PSResourceGet.Type.Tests.ps1 b/Tests/PSResourceGet.Type.Tests.ps1 index 0f94ce4..b72fbc0 100644 --- a/Tests/PSResourceGet.Type.Tests.ps1 +++ b/Tests/PSResourceGet.Type.Tests.ps1 @@ -282,4 +282,27 @@ Describe 'PSResourceGet script' { -ParameterFilter { $TrustRepository -eq $true } } } + + Context 'Version range (pass-through)' { + It 'Forwards a NuGet range straight to Install-PSResource -Version' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 1 -Exactly ` + -ParameterFilter { $Version -eq '[2.0.0,3.0.0)' } + } + + It 'Skips install when an installed version already satisfies the range' { + InModuleScope PSDepend { + Mock Get-Module { [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.5.0' } } ` + -ParameterFilter { $ListAvailable } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '[2.0.0,3.0.0)' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep + } + Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/Test-VersionInRange.Tests.ps1 b/Tests/Test-VersionInRange.Tests.ps1 new file mode 100644 index 0000000..66e12a6 --- /dev/null +++ b/Tests/Test-VersionInRange.Tests.ps1 @@ -0,0 +1,105 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'Test-VersionInRange' { + + Context 'Null and empty inputs' { + + It 'Returns false when Version is empty' { + InModuleScope PSDepend { + Test-VersionInRange -Version '' -Required '[1.0,2.0)' + } | Should -BeFalse + } + + It 'Returns false when Required is empty' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.0.0' -Required '' + } | Should -BeFalse + } + } + + Context 'Exact requests' { + + It 'Returns true for a matching exact version' { + InModuleScope PSDepend { + Test-VersionInRange -Version '3.2.1' -Required '3.2.1' + } | Should -BeTrue + } + + It 'Returns false for a non-matching exact version' { + InModuleScope PSDepend { + Test-VersionInRange -Version '3.2.0' -Required '3.2.1' + } | Should -BeFalse + } + } + + Context 'Bounded ranges' { + + It 'Returns true inside the range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.5.0' -Required '[2.2.3,3.0)' + } | Should -BeTrue + } + + It 'Honours an inclusive lower bound' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.2.3' -Required '[2.2.3,3.0)' + } | Should -BeTrue + } + + It 'Honours an exclusive upper bound' { + InModuleScope PSDepend { + Test-VersionInRange -Version '3.0.0' -Required '[2.2.3,3.0)' + } | Should -BeFalse + } + + It 'Returns false below the range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.2.2' -Required '[2.2.3,3.0)' + } | Should -BeFalse + } + + It 'Honours an exclusive lower bound' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.0.0' -Required '(1.0.0,2.0.0]' + } | Should -BeFalse + } + } + + Context 'Open-ended ranges' { + + It 'Returns true at or above a minimum-only range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '5.0.0' -Required '[2.0,)' + } | Should -BeTrue + } + + It 'Returns false below a minimum-only range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.5.0' -Required '[2.0,)' + } | Should -BeFalse + } + + It 'Returns true below a maximum-only range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '2.9.0' -Required '(,3.0)' + } | Should -BeTrue + } + } + + Context 'Malformed request' { + + It 'Returns false for a malformed range' { + InModuleScope PSDepend { + Test-VersionInRange -Version '1.0.0' -Required '[1.0,2.0' + } | Should -BeFalse + } + } +} diff --git a/adr/0001-nuget-version-ranges.md b/adr/0001-nuget-version-ranges.md new file mode 100644 index 0000000..aca9e67 --- /dev/null +++ b/adr/0001-nuget-version-ranges.md @@ -0,0 +1,43 @@ +# Version ranges use NuGet range syntax, resolved to an exact version + +## Context + +Issues #65 and #91 asked for version-range support (comparison operators, and +Minimum/Maximum version). The Version field has always been a single string +holding an exact version, `latest`, or `''`. + +## Decision + +A VersionRange is expressed in **NuGet range syntax** (`[2.2.3,3.0)`, `[2.0,)`, +`(,3.0)`) carried in the existing Version field. A string is treated as a range +only when it contains a range delimiter (`[`, `]`, `(`, `)`, `,`); a bare version +(`3.2.1`) keeps its existing exact-match meaning. There is no OR support — a +range is a single contiguous interval. + +For PSResourceGet the range is passed straight to `Install-PSResource -Version`. +For PSGalleryModule and PSGalleryNuget we **resolve the range to an exact version +ourselves** (find available versions → filter with `Test-VersionInRange` → select +the maximum that satisfies → install that exact version) rather than translating +to native installer parameters. + +## Considered Options + +- **Named keys (`MinimumVersion`/`MaximumVersion`).** Most readable, but requires + adding fields to the Dependency object in `Get-Dependency.ps1` and the type — + a much larger blast radius than reusing the Version string. +- **Operator strings (`>2.2.3,<3.0`).** The comma's AND/OR meaning was ambiguous + (flagged on #65) and there is no established grammar to point users to. +- **Strict NuGet semantics** (bare `1.0` means `>= 1.0`). Rejected: it would + silently change the meaning of every existing requirements file. + +## Consequences + +- `Install-Module`'s `-MinimumVersion`/`-MaximumVersion` are **inclusive only** and + cannot express the exclusive bound in `(1.0,2.0)` or `(,3.0)`. Translating a + range to those parameters would leak excluded versions, so we deliberately + bypass them and install an exact resolved version. A future reader should not + "simplify" this back to native range parameters. +- Range semantics live in one place (`Test-VersionInRange`, built on a shared + `Compare-Version` ordering primitive), not reinterpreted per installer. +- We deviate from strict NuGet, where bare `1.0` means a minimum — here it stays + exact for backward compatibility. From d89a7524c4a971f75aa82ac3a4b9c8632f6e84c9 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Fri, 19 Jun 2026 15:15:51 -0700 Subject: [PATCH 2/3] fix: fail fast on malformed version ranges in PSGalleryModule A malformed NuGet range (e.g. [1.0.0,2.0.0) caused ConvertFrom-VersionRange to return $null, leaving RequiredVersion unset so the script silently fell back to installing the latest version instead of the constrained range. Error out and skip install when the range cannot be parsed. Co-Authored-By: Claude Opus 4.8 --- PSDepend/PSDependScripts/PSGalleryModule.ps1 | 6 +++++- Tests/PSGalleryModule.Type.Tests.ps1 | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/PSDepend/PSDependScripts/PSGalleryModule.ps1 b/PSDepend/PSDependScripts/PSGalleryModule.ps1 index dee4808..a43c6d7 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -205,7 +205,11 @@ if ($Repository) { $versionRange = $null if ($Version -and $Version -ne 'latest') { $versionRange = ConvertFrom-VersionRange -Version $Version - if ($versionRange -and $versionRange.IsExact) { + if (-not $versionRange) { + Write-Error "Could not parse version [$Version] for [$Name]; expected an exact version or a valid NuGet range." + return + } + if ($versionRange.IsExact) { $Params.add('RequiredVersion', $versionRange.Exact) } } diff --git a/Tests/PSGalleryModule.Type.Tests.ps1 b/Tests/PSGalleryModule.Type.Tests.ps1 index 8b51826..6a81261 100644 --- a/Tests/PSGalleryModule.Type.Tests.ps1 +++ b/Tests/PSGalleryModule.Type.Tests.ps1 @@ -248,5 +248,16 @@ Describe 'PSGalleryModule script' { } Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 } + + It 'Errors and skips install for a malformed range instead of falling back to latest' { + InModuleScope PSDepend { + Mock Get-Module { } -ParameterFilter { $ListAvailable } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[1.0.0,2.0.0' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -ErrorAction SilentlyContinue + } + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + } } } From 70a4dc637fec9036a0e273f74dfe56f71e747905 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Fri, 19 Jun 2026 15:37:00 -0700 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20address=20team=20review=20?= =?UTF-8?q?=E2=80=94=20fail-fast=20parity,=20shared=20resolver,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fail fast on malformed version ranges in PSGalleryNuget and PSResourceGet, matching the PSGalleryModule contract (+ regression tests for both). - Extract the duplicated range-resolution loop into a shared Resolve-VersionInRange helper used by PSGalleryModule and PSGalleryNuget (+ unit tests). - Route PSResourceGet's import-version range detection through ConvertFrom-VersionRange instead of a divergent inline regex that dropped the ')' delimiter. - Fix $params.Add casing nit and the "repository []" empty-repository error message in PSGalleryModule. - Trace Compare-Version's ordinal string fallback via Write-Verbose. - Correct the stale Test-VersionEquality description; document PSResourceGet's range pass-through (help, CONTEXT.md) and add a range example. - Tighten the PSGalleryModule resolve test (-ParameterFilter { $AllVersions }), add a null-Version case for Test-VersionInRange, and assert PSResourceGet pass-through does not pre-resolve via Find-PSResource. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 2 +- PSDepend/PSDependScripts/PSGalleryModule.ps1 | 17 +++---- PSDepend/PSDependScripts/PSGalleryNuget.ps1 | 15 +++--- PSDepend/PSDependScripts/PSResourceGet.ps1 | 30 +++++++++++- PSDepend/Private/Compare-Version.ps1 | 2 + PSDepend/Private/Resolve-VersionInRange.ps1 | 42 +++++++++++++++++ PSDepend/Private/Test-VersionEquality.ps1 | 8 ++-- Tests/PSGalleryModule.Type.Tests.ps1 | 2 +- Tests/PSGalleryNuget.Type.Tests.ps1 | 9 ++++ Tests/PSResourceGet.Type.Tests.ps1 | 10 ++++ Tests/Resolve-VersionInRange.Tests.ps1 | 48 ++++++++++++++++++++ Tests/Test-VersionInRange.Tests.ps1 | 6 +++ 12 files changed, 166 insertions(+), 25 deletions(-) create mode 100644 PSDepend/Private/Resolve-VersionInRange.ps1 create mode 100644 Tests/Resolve-VersionInRange.Tests.ps1 diff --git a/CONTEXT.md b/CONTEXT.md index c3de87c..c14b1cd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,7 +52,7 @@ _Avoid_: version spec, version constraint, MinimumVersion/MaximumVersion - A **Dependency** may carry zero or more **Tags** - A **DependencyScript** receives a **Dependency** and a set of **PSDependAction** flags on each invocation - **Target** is a field on a **Dependency** interpreted differently by each **DependencyScript** -- A **Dependency**'s Version field carries either an exact version or a **VersionRange**; each gallery **DependencyScript** resolves a **VersionRange** to a concrete version to install +- A **Dependency**'s Version field carries either an exact version or a **VersionRange**; the `PSGalleryModule` and `PSGalleryNuget` **DependencyScripts** resolve a **VersionRange** to a concrete version to install, while `PSResourceGet` passes the range to `Install-PSResource` and lets it resolve ## Example dialogue diff --git a/PSDepend/PSDependScripts/PSGalleryModule.ps1 b/PSDepend/PSDependScripts/PSGalleryModule.ps1 index a43c6d7..4c3d9a4 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -202,6 +202,8 @@ if ($Repository) { # Exact versions map straight to RequiredVersion. Ranges have no Install-Module # parameter, so they are resolved to a concrete version just before install. +# $versionRange is kept only to detect exact-vs-range here; the resolution below +# re-derives the range per candidate via Test-VersionInRange. $versionRange = $null if ($Version -and $Version -ne 'latest') { $versionRange = ConvertFrom-VersionRange -Version $Version @@ -210,7 +212,7 @@ if ($Version -and $Version -ne 'latest') { return } if ($versionRange.IsExact) { - $Params.add('RequiredVersion', $versionRange.Exact) + $params.Add('RequiredVersion', $versionRange.Exact) } } @@ -300,17 +302,12 @@ if ($versionRange -and -not $versionRange.IsExact -and $PSDependAction -contains if ($Credential) { $resolveParams.Add('Credential', $Credential) } if ($AllowPrerelease) { $resolveParams.Add('AllowPrerelease', $AllowPrerelease) } - $resolvedVersion = $null - foreach ($candidate in (Find-Module @resolveParams -AllVersions)) { - $candidateVersion = $candidate.Version.ToString() - if ((Test-VersionInRange -Version $candidateVersion -Required $Version) -and - ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidateVersion -DifferenceVersion $resolvedVersion) -gt 0)) { - $resolvedVersion = $candidateVersion - } - } + $candidates = Find-Module @resolveParams -AllVersions | ForEach-Object { $_.Version.ToString() } + $resolvedVersion = Resolve-VersionInRange -Candidate $candidates -Required $Version if (-not $resolvedVersion) { - Write-Error "No version of [$Name] in repository [$Repository] satisfies range [$Version]" + $repositoryLabel = if ($Repository) { $Repository } else { 'the default repositories' } + Write-Error "No version of [$Name] in [$repositoryLabel] satisfies range [$Version]" return } Write-Verbose "Resolved range [$Version] to version [$resolvedVersion] for [$Name]" diff --git a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 index d684392..9a32d84 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -178,14 +178,13 @@ if ( $PSDependAction -contains 'Test' -and $PSDependAction.count -eq 1) { $installVersion = $Version if ($Version -and $Version -notlike 'latest') { $range = ConvertFrom-VersionRange -Version $Version - if ($range -and -not $range.IsExact) { - $resolvedVersion = $null - foreach ($candidate in (Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential)) { - if ((Test-VersionInRange -Version $candidate.Version -Required $Version) -and - ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidate.Version -DifferenceVersion $resolvedVersion) -gt 0)) { - $resolvedVersion = $candidate.Version - } - } + if (-not $range) { + Write-Error "Could not parse version [$Version] for [$Name]; expected an exact version or a valid NuGet range." + return + } + if (-not $range.IsExact) { + $candidates = (Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential).Version + $resolvedVersion = Resolve-VersionInRange -Candidate $candidates -Required $Version if (-not $resolvedVersion) { Write-Error "No version of [$Name] at source [$Source] satisfies range [$Version]" return diff --git a/PSDepend/PSDependScripts/PSResourceGet.ps1 b/PSDepend/PSDependScripts/PSResourceGet.ps1 index 89ce4d4..90672ae 100644 --- a/PSDepend/PSDependScripts/PSResourceGet.ps1 +++ b/PSDepend/PSDependScripts/PSResourceGet.ps1 @@ -13,7 +13,10 @@ Relevant Dependency metadata: Name: The name of the module to install Version: Used to identify existing installs and as -Version for installation. - Supports NuGet range syntax (e.g. '[1.0.0, ]'). Defaults to 'latest'. + Also accepts a NuGet version range (e.g. '[2.2.3,3.0)', '[2.0,)', + '(,3.0)'); a bare version (e.g. '3.2.1') still means that exact + version. Ranges are passed through to Install-PSResource, which + resolves them. Defaults to 'latest'. Target: Used as -Scope for Install-PSResource (CurrentUser or AllUsers). If this is a filesystem path, Save-PSResource is used instead. Defaults to 'CurrentUser'. @@ -117,6 +120,17 @@ } # Install the latest version of PowerCLI, allowing prerelease versions. + + .EXAMPLE + @{ + BuildHelpers = @{ + DependencyType = 'PSResourceGet' + Version = '[2.0.0,3.0.0)' + } + } + + # Install the highest BuildHelpers version that is >= 2.0.0 and < 3.0.0 + # (NuGet range syntax). The range is passed to Install-PSResource -Version. #> [CmdletBinding()] @@ -156,6 +170,18 @@ if (-not $Version) { $Version = 'latest' } +# PSResourceGet understands NuGet ranges natively, so a range is passed straight +# through to Install-PSResource. Parsing here fails fast on malformed input and +# detects ranges for import resolution, keeping range detection in one place. +$versionRange = $null +if ($Version -and $Version -ne 'latest') { + $versionRange = ConvertFrom-VersionRange -Version $Version + if (-not $versionRange) { + Write-Error "Could not parse version [$Version] for [$Name]; expected an exact version or a valid NuGet range." + return + } +} + # Target doubles as Scope: AllUsers/CurrentUser = install scope; any other value = filesystem path if (-not $Dependency.Target) { $Scope = 'CurrentUser' @@ -310,7 +336,7 @@ if ($PSDependAction -contains 'Install') { # Conditional import — params['Version'] may be a NuGet range; resolve to a concrete installed version $importVs = $params['Version'] -if ($importVs -and $importVs -match '[\[\](,]') { +if ($versionRange -and -not $versionRange.IsExact) { $importVs = Get-Module -ListAvailable -Name $ModuleName -ErrorAction SilentlyContinue | Measure-Object -Property Version -Maximum | Select-Object -ExpandProperty Maximum diff --git a/PSDepend/Private/Compare-Version.ps1 b/PSDepend/Private/Compare-Version.ps1 index 3b0a404..e46aa53 100644 --- a/PSDepend/Private/Compare-Version.ps1 +++ b/PSDepend/Private/Compare-Version.ps1 @@ -71,6 +71,8 @@ function Compare-Version { } # Neither type parses both: ordinal string comparison, clamped to -1/0/1. + # Trace this: a wrong answer here (e.g. a typo'd '1.2.x') is otherwise silent. + Write-Verbose "Compare-Version falling back to ordinal string comparison for [$ReferenceVersion] vs [$DifferenceVersion]" return [Math]::Sign( [string]::Compare($ReferenceVersion, $DifferenceVersion, [System.StringComparison]::OrdinalIgnoreCase) ) diff --git a/PSDepend/Private/Resolve-VersionInRange.ps1 b/PSDepend/Private/Resolve-VersionInRange.ps1 new file mode 100644 index 0000000..78b5836 --- /dev/null +++ b/PSDepend/Private/Resolve-VersionInRange.ps1 @@ -0,0 +1,42 @@ +function Resolve-VersionInRange { + <# + .SYNOPSIS + Return the highest candidate version that satisfies a version range. + + .DESCRIPTION + Gallery installers that cannot express a NuGet range natively (Install-Module, + nuget.exe) need a single concrete version. This selects the highest of the + supplied candidate versions that satisfies the requested range (exact or + range), using the shared Test-VersionInRange predicate and Compare-Version + ordering primitive so every installer resolves a range identically. + + Returns the winning version string, or $null when no candidate satisfies the + range. + + .PARAMETER Candidate + The available version strings to choose from. + + .PARAMETER Required + The requested version string (exact or NuGet range). + + .EXAMPLE + Resolve-VersionInRange -Candidate '1.9.0', '2.5.0', '3.0.0' -Required '[2.0.0,3.0.0)' + + Returns '2.5.0' - the highest candidate inside the half-open range. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [string[]]$Candidate, + [string]$Required + ) + + $resolved = $null + foreach ($version in $Candidate) { + if ((Test-VersionInRange -Version $version -Required $Required) -and + ($null -eq $resolved -or (Compare-Version -ReferenceVersion $version -DifferenceVersion $resolved) -gt 0)) { + $resolved = $version + } + } + $resolved +} diff --git a/PSDepend/Private/Test-VersionEquality.ps1 b/PSDepend/Private/Test-VersionEquality.ps1 index 52fe904..6d5f90a 100644 --- a/PSDepend/Private/Test-VersionEquality.ps1 +++ b/PSDepend/Private/Test-VersionEquality.ps1 @@ -4,9 +4,11 @@ Compare two versions by casting and comparing individual components. .DESCRIPTION - Compare two version strings by attempting to parse them as System.Version - and System.Management.Automation.SemanticVersion, and comparing their - components. If parsing fails, fall back to string comparison. + Return $true when two version strings represent the same version. Equality is + the zero case of the shared Compare-Version ordering primitive, which tries + SemanticVersion first (honouring pre-release labels), falls back to a + normalised System.Version (so 1.2.3 equals 1.2.3.0), and finally to an + ordinal string comparison. Null or empty inputs are never equal. .PARAMETER ReferenceVersion The reference version string to compare against. diff --git a/Tests/PSGalleryModule.Type.Tests.ps1 b/Tests/PSGalleryModule.Type.Tests.ps1 index 6a81261..b99ea5f 100644 --- a/Tests/PSGalleryModule.Type.Tests.ps1 +++ b/Tests/PSGalleryModule.Type.Tests.ps1 @@ -215,7 +215,7 @@ Describe 'PSGalleryModule script' { [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.5.0' } [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'3.0.0' } ) - } + } -ParameterFilter { $AllVersions } } $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index 0ced47a..3e4edd8 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -126,5 +126,14 @@ Describe 'PSGalleryNuget script' { } Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 } + + It 'Errors and skips nuget install for a malformed range instead of passing it to nuget' { + $targetDir = (New-Item 'TestDrive:/psgnuget-range-malformed' -ItemType Directory -Force).FullName + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Target $targetDir -Version '[1.0.0,2.0.0' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -ErrorAction SilentlyContinue + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 + } } } diff --git a/Tests/PSResourceGet.Type.Tests.ps1 b/Tests/PSResourceGet.Type.Tests.ps1 index b72fbc0..904f149 100644 --- a/Tests/PSResourceGet.Type.Tests.ps1 +++ b/Tests/PSResourceGet.Type.Tests.ps1 @@ -291,6 +291,16 @@ Describe 'PSResourceGet script' { } Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 1 -Exactly ` -ParameterFilter { $Version -eq '[2.0.0,3.0.0)' } + # Pass-through: PSResourceGet resolves the range itself, so we must not pre-resolve it. + Should -Invoke -CommandName Find-PSResource -ModuleName PSDepend -Times 0 + } + + It 'Errors and skips install for a malformed range instead of forwarding it' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '[1.0.0,2.0.0' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -ErrorAction SilentlyContinue + } + Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 0 } It 'Skips install when an installed version already satisfies the range' { diff --git a/Tests/Resolve-VersionInRange.Tests.ps1 b/Tests/Resolve-VersionInRange.Tests.ps1 new file mode 100644 index 0000000..308dbbc --- /dev/null +++ b/Tests/Resolve-VersionInRange.Tests.ps1 @@ -0,0 +1,48 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'Resolve-VersionInRange' { + + It 'Returns the highest candidate inside a half-open range' { + InModuleScope PSDepend { + Resolve-VersionInRange -Candidate '1.9.0', '2.5.0', '3.0.0' -Required '[2.0.0,3.0.0)' + } | Should -Be '2.5.0' + } + + It 'Picks the maximum regardless of candidate order' { + InModuleScope PSDepend { + Resolve-VersionInRange -Candidate '2.5.0', '2.1.0', '2.9.0' -Required '[2.0.0,3.0.0)' + } | Should -Be '2.9.0' + } + + It 'Returns the exact version when it is available' { + InModuleScope PSDepend { + Resolve-VersionInRange -Candidate '1.0.0', '2.0.0', '3.0.0' -Required '2.0.0' + } | Should -Be '2.0.0' + } + + It 'Returns null when no candidate satisfies the range' { + InModuleScope PSDepend { + Resolve-VersionInRange -Candidate '1.0.0', '3.5.0' -Required '[2.0.0,3.0.0)' + } | Should -BeNullOrEmpty + } + + It 'Returns null for an empty candidate set' { + InModuleScope PSDepend { + Resolve-VersionInRange -Candidate @() -Required '[2.0.0,3.0.0)' + } | Should -BeNullOrEmpty + } + + It 'Excludes the exclusive upper bound' { + InModuleScope PSDepend { + Resolve-VersionInRange -Candidate '2.9.0', '3.0.0' -Required '[2.0.0,3.0.0)' + } | Should -Be '2.9.0' + } +} diff --git a/Tests/Test-VersionInRange.Tests.ps1 b/Tests/Test-VersionInRange.Tests.ps1 index 66e12a6..861c72c 100644 --- a/Tests/Test-VersionInRange.Tests.ps1 +++ b/Tests/Test-VersionInRange.Tests.ps1 @@ -18,6 +18,12 @@ Describe 'Test-VersionInRange' { } | Should -BeFalse } + It 'Returns false when Version is null' { + InModuleScope PSDepend { + Test-VersionInRange -Version $null -Required '[1.0,2.0)' + } | Should -BeFalse + } + It 'Returns false when Required is empty' { InModuleScope PSDepend { Test-VersionInRange -Version '1.0.0' -Required ''