From a017413230cf3001fdcb9c885a858299dd86dc4b Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 30 Sep 2026 20:24:57 +0000 Subject: [PATCH 1/6] feat: npm-style lock files with transitive resolution Add Update-PSDependLock, which resolves every dependency and its transitive dependencies to exact versions and writes .lock.json next to the DependencyFile. One version is locked per DependencyType::Name across the file, constraints from every parent are intersected (Join-VersionRange), and conflicts fail the update. Get-Dependency and Invoke-PSDepend honour an existing lock automatically: locked dependencies are pinned, locked transitive packages are materialised as Name@Version dependencies that install first, and a lock that no longer matches its DependencyFile is an error (-IgnoreLock opts out). New Resolve PSDependAction on PSGalleryModule, PSResourceGet, PSGalleryNuget, Nuget, Chocolatey and Npm queries the source without installing. Npm pins only the declared package; npm's package-lock.json governs its subtree. Also fix Invoke-DependencyScript ignoring -PSDependTypePath. --- CHANGELOG.md | 21 ++ CLAUDE.md | 2 +- CONTEXT.md | 9 + PSDepend/PSDepend.psd1 | 3 +- PSDepend/PSDependScripts/Chocolatey.ps1 | 52 +++- PSDepend/PSDependScripts/Npm.ps1 | 31 ++- PSDepend/PSDependScripts/Nuget.ps1 | 37 ++- PSDepend/PSDependScripts/PSGalleryModule.ps1 | 61 ++++- PSDepend/PSDependScripts/PSGalleryNuget.ps1 | 37 ++- PSDepend/PSDependScripts/PSResourceGet.ps1 | 75 +++++- .../ConvertFrom-NugetDependencyString.ps1 | 65 +++++ PSDepend/Private/Export-PSDependLock.ps1 | 32 +++ PSDepend/Private/Find-NodeModule.ps1 | 47 ++++ PSDepend/Private/Get-PSDependLockPath.ps1 | 27 ++ PSDepend/Private/Import-PSDependLock.ps1 | 52 ++++ PSDepend/Private/Join-VersionRange.ps1 | 114 +++++++++ PSDepend/Private/Merge-PSDependLock.ps1 | 150 ++++++++++++ PSDepend/Private/Resolve-PSDependLock.ps1 | 231 ++++++++++++++++++ PSDepend/Public/Get-Dependency.ps1 | 24 +- PSDepend/Public/Invoke-DependencyScript.ps1 | 13 +- PSDepend/Public/Invoke-PSDepend.ps1 | 16 +- PSDepend/Public/Update-PSDependLock.ps1 | 110 +++++++++ PSDepend/en-US/about_PSDepend.help.txt | 27 ++ README.md | 15 ++ Tests/Chocolatey.Type.Tests.ps1 | 101 ++++++++ ...onvertFrom-NugetDependencyString.Tests.ps1 | 98 ++++++++ Tests/Join-VersionRange.Tests.ps1 | 100 ++++++++ Tests/Npm.Type.Tests.ps1 | 43 ++++ Tests/Nuget.Type.Tests.ps1 | 60 +++++ Tests/PSDependLock.Tests.ps1 | 184 ++++++++++++++ Tests/PSGalleryModule.Type.Tests.ps1 | 84 +++++++ Tests/PSGalleryNuget.Type.Tests.ps1 | 52 ++++ Tests/PSResourceGet.Type.Tests.ps1 | 91 ++++++- Tests/Shared/FakeResolver.ps1 | 84 +++++++ docs/en-US/Get-Dependency.md | 21 +- docs/en-US/Invoke-PSDepend.md | 24 +- docs/en-US/Update-PSDependLock.md | 210 ++++++++++++++++ 37 files changed, 2372 insertions(+), 31 deletions(-) create mode 100644 PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 create mode 100644 PSDepend/Private/Export-PSDependLock.ps1 create mode 100644 PSDepend/Private/Find-NodeModule.ps1 create mode 100644 PSDepend/Private/Get-PSDependLockPath.ps1 create mode 100644 PSDepend/Private/Import-PSDependLock.ps1 create mode 100644 PSDepend/Private/Join-VersionRange.ps1 create mode 100644 PSDepend/Private/Merge-PSDependLock.ps1 create mode 100644 PSDepend/Private/Resolve-PSDependLock.ps1 create mode 100644 PSDepend/Public/Update-PSDependLock.ps1 create mode 100644 Tests/ConvertFrom-NugetDependencyString.Tests.ps1 create mode 100644 Tests/Join-VersionRange.Tests.ps1 create mode 100644 Tests/PSDependLock.Tests.ps1 create mode 100644 Tests/Shared/FakeResolver.ps1 create mode 100644 docs/en-US/Update-PSDependLock.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 95713e0..f0b0573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- npm-style lock files. `Update-PSDependLock` resolves every dependency and + their transitive dependencies to exact versions and writes + `.lock.json` next to the dependency file (`requirements.psd1` -> + `requirements.lock.json`). One version is locked per package across the + whole file; conflicting constraints fail the update. `Invoke-PSDepend` and + `Get-Dependency` honor an existing lock automatically, install locked + transitive packages first, and error when the dependency file no longer + matches the lock; `-IgnoreLock` opts out. +- New `Resolve` PSDependAction for `PSGalleryModule`, `PSResourceGet`, + `PSGalleryNuget`, `Nuget`, `Chocolatey` and `Npm`: query the source for the + highest version satisfying `Version` and report its dependencies as NuGet + ranges, without installing. `Npm` pins only the declared package and leaves + its subtree to npm's `package-lock.json`. + +### Fixed + +- `Invoke-DependencyScript -PSDependTypePath` is now passed through to the + type/script lookup instead of always reading the module's `PSDependMap.psd1`. + ## [0.6.0] - 2026-09-30 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index e7ff800..7604677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ No compilation; files are staged verbatim to `Output\` — do not edit files und Two files must be updated together: -1. **`PSDepend/PSDependScripts/.ps1`** — handler script. Must include comment-based help and a `PSDependAction` parameter accepting `Install`, `Test`, and `Import` values. +1. **`PSDepend/PSDependScripts/.ps1`** — handler script. Must include comment-based help and a `PSDependAction` parameter accepting `Install`, `Test`, and `Import` values. Optionally accept `Resolve` (lock support): query the source only and emit one `PSDepend.ResolvedDependency` object (`Name`, exact `Version`, `Dependencies` hashtable of name → NuGet range or `'latest'`); see `PSGalleryModule.ps1`. 2. **`PSDepend/PSDependMap.psd1`** — registers the type, maps it to the script, and sets `Supports` to control platform filtering (`windows`, `core`, `macos`, `linux`). See `Git.ps1` and `PSGalleryModule.ps1` as reference implementations. diff --git a/CONTEXT.md b/CONTEXT.md index c14b1cd..7f457ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,6 +44,14 @@ _Avoid_: filter, category, label 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 +**Lock**: +A `.lock.json` file next to a DependencyFile, written by `Update-PSDependLock`, that records one exact version per `DependencyType::Name` for every Dependency (and its transitive dependencies) whose DependencyScript supports the Resolve PSDependAction. Consumed automatically by `Get-Dependency`/`Invoke-PSDepend`; out of date when the DependencyFile no longer matches. +_Avoid_: lockfile (npm's), pin file, freeze + +**Resolve**: +The PSDependAction that asks a DependencyScript for the highest version satisfying a Version (or VersionRange) at its source, plus that version's own dependencies as VersionRanges, without installing. Runs alone; only DependencyScripts that opt in support it. +_Avoid_: lookup, query, find + ## Relationships - A **DependencyFile** contains one or more **Dependencies** and at most one **PSDependOptions** block @@ -53,6 +61,7 @@ _Avoid_: version spec, version constraint, MinimumVersion/MaximumVersion - 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**; 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 +- A **Lock** belongs to exactly one **DependencyFile**; it is produced by invoking **Resolve** on each **DependencyScript** that supports it and, when applied, pins each **Dependency**'s Version and adds locked transitive packages as **Prerequisites** of the **Dependency** that pulled them in ## Example dialogue diff --git a/PSDepend/PSDepend.psd1 b/PSDepend/PSDepend.psd1 index e97df8a..bab808c 100644 --- a/PSDepend/PSDepend.psd1 +++ b/PSDepend/PSDepend.psd1 @@ -67,7 +67,8 @@ 'Install-Dependency', 'Invoke-DependencyScript', 'Invoke-PSDepend', - 'Test-Dependency' + 'Test-Dependency', + 'Update-PSDependLock' ) # Cmdlets to export from this module diff --git a/PSDepend/PSDependScripts/Chocolatey.ps1 b/PSDepend/PSDependScripts/Chocolatey.ps1 index bccc40b..c5ab39b 100644 --- a/PSDepend/PSDependScripts/Chocolatey.ps1 +++ b/PSDepend/PSDependScripts/Chocolatey.ps1 @@ -1,4 +1,4 @@ -# cspell:ignore lessmsi +# cspell:ignore lessmsi <# .SYNOPSIS Installs a package from a Chocolatey repository. @@ -24,10 +24,11 @@ Defaults to https://community.chocolatey.org/install.ps1 .PARAMETER PSDependAction - Test, or Install the package. Defaults to Install + Test, Install, or Resolve the package. Defaults to Install Test: Return true or false on whether the dependency is in place Install: Install the dependency + Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @{ @@ -76,7 +77,7 @@ param( [string]$ChocoInstallScriptUrl = 'https://community.chocolatey.org/install.ps1', - [ValidateSet('Test', 'Install')] + [ValidateSet('Test', 'Install', 'Resolve')] [string[]]$PSDependAction = @('Install') ) @@ -237,6 +238,51 @@ if (-not $Dependency.Source -or $Source -eq '') { $Credential = $Dependency.Credential +if ($PSDependAction -contains 'Resolve') { + # Chocolatey feeds are NuGet v2 OData; query the feed directly so Resolve never needs choco.exe. + if ($Source -notmatch '^https?://') { + Write-Error "Resolve for [$Name] requires a NuGet v2 feed URL as Source; got [$Source]" + return + } + + $findParams = @{ + Name = $Name + PackageSourceUrl = $Source + } + if ($Credential) { + $findParams.Credential = $Credential + } + # choco install/upgrade never picks a prerelease without --pre, so Resolve ignores them too. + $packages = @(Find-NugetPackage @findParams | Where-Object { $_.Version -and $_.Properties.IsPrerelease -ne 'true' }) + + $selected = $null + if ($Version -eq 'latest') { + foreach ($package in $packages) { + if ($null -eq $selected -or (Compare-Version -ReferenceVersion $package.Version -DifferenceVersion $selected.Version) -gt 0) { + $selected = $package + } + } + } else { + $resolvedVersion = Resolve-VersionInRange -Candidate $packages.Version -Required $Version + if ($resolvedVersion) { + $selected = $packages | Where-Object { $_.Version -eq $resolvedVersion } | Select-Object -First 1 + } + } + + if ($null -eq $selected) { + Write-Error "No version of [$Name] at [$Source] satisfies [$Version]" + return + } + + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $Name + Version = $selected.Version + Dependencies = ConvertFrom-NugetDependencyString -Dependencies ([string]$selected.Properties.Dependencies) + } + return +} + $versionRange = $null if ($Version -ne 'latest') { $versionRange = ConvertFrom-VersionRange -Version $Version diff --git a/PSDepend/PSDependScripts/Npm.ps1 b/PSDepend/PSDependScripts/Npm.ps1 index 32dd211..c8eeaa0 100644 --- a/PSDepend/PSDependScripts/Npm.ps1 +++ b/PSDepend/PSDependScripts/Npm.ps1 @@ -7,6 +7,10 @@ Note: We require npm in your path. + Lock behaviour (Resolve): PSDepend's lock pins only the declared package to an + exact version. Transitive node dependencies are not resolved by PSDepend; npm's + own package-lock.json governs the package's subtree. + Relevant Dependency metadata: DependencyName (Key): Node Package Name Version: Version of the node package to install; defaults to latest. @@ -23,10 +27,11 @@ If specified, the node package will be installed globally. .PARAMETER PSDependAction - Test or Install the dependency. Defaults to Install + Test, Install or Resolve the dependency. Defaults to Install Test: Return true or false on whether the dependency is in place Install: Install the dependency + Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @{ @@ -59,7 +64,7 @@ param ( [PSTypeName('PSDepend.Dependency')] [PSObject[]]$Dependency, - [ValidateSet('Test', 'Install')] + [ValidateSet('Test', 'Install', 'Resolve')] [string[]]$PSDependAction = @('Install'), [switch]$Force, [switch]$Global @@ -81,6 +86,28 @@ If (-not [string]::IsNullOrEmpty($Target) -and $Target -ne 'global') { } } #endregion Extract Dependency Data +#region Resolve Action +If ($PSDependAction -contains 'Resolve') { + $Candidates = @(Find-NodeModule -PackageName $Name -Version $Version) + $Resolved = $null + foreach ($Candidate in $Candidates) { + if ($null -eq $Resolved -or (Compare-Version -ReferenceVersion $Candidate -DifferenceVersion $Resolved) -gt 0) { + $Resolved = $Candidate + } + } + if ($null -eq $Resolved) { + Write-Error "No version of [$Name] at [npm] satisfies [$Version]" + return + } + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $Name + Version = $Resolved + Dependencies = @{} + } + return +} +#endregion Resolve Action #region Test Action If ($PSDependAction -contains 'Test') { If ([string]::IsNullOrEmpty($Target)) { diff --git a/PSDepend/PSDependScripts/Nuget.ps1 b/PSDepend/PSDependScripts/Nuget.ps1 index 143ce4a..82becfc 100644 --- a/PSDepend/PSDependScripts/Nuget.ps1 +++ b/PSDepend/PSDependScripts/Nuget.ps1 @@ -17,10 +17,11 @@ If specified and Target already exists, remove existing item before saving .PARAMETER PSDependAction - Test, or Install the package. Defaults to Install + Test, Install, or Resolve the package. Defaults to Install Test: Return true or false on whether the dependency is in place Install: Install the dependency + Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @@ -73,7 +74,7 @@ param( [switch]$Force, - [ValidateSet('Test', 'Install')] + [ValidateSet('Test', 'Install', 'Resolve')] [string[]]$PSDependAction = @('Install'), [Alias('DLLName')] @@ -95,6 +96,36 @@ if (-not $Dependency.Source) { $Source = 'https://www.nuget.org/api/v2/' } +$Credential = $Dependency.Credential + +if ($PSDependAction -contains 'Resolve') { + $packages = @(Find-NugetPackage -Name $DependencyName -PackageSourceUrl $Source -Credential $Credential) + $resolvedVersion = $null + if ($Version -eq 'latest') { + $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) + foreach ($package in $stable) { + if (-not $resolvedVersion -or (Compare-Version $package.Version $resolvedVersion) -gt 0) { + $resolvedVersion = $package.Version + } + } + } + else { + $resolvedVersion = Resolve-VersionInRange -Candidate @($packages.Version) -Required $Version + } + if (-not $resolvedVersion) { + Write-Error "No version of [$DependencyName] at [$Source] satisfies [$Version]" + return + } + $resolved = $packages | Where-Object { $_.Version -eq $resolvedVersion } | Select-Object -First 1 + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $DependencyName + Version = $resolvedVersion + Dependencies = ConvertFrom-NugetDependencyString -Dependencies $resolved.Properties.Dependencies + } + return +} + # We use target as a proxy for Scope $Target = $Dependency.Target if (-not $Dependency.Target) { @@ -102,8 +133,6 @@ if (-not $Dependency.Target) { return } -$Credential = $Dependency.Credential - if (-not (Get-Command Nuget -ErrorAction SilentlyContinue)) { if (Test-PlatformSupport -Type 'Nuget' -Support 'windows', 'core') { BootStrap-Nuget -NugetPath $NuGetPath diff --git a/PSDepend/PSDependScripts/PSGalleryModule.ps1 b/PSDepend/PSDependScripts/PSGalleryModule.ps1 index 4c3d9a4..5d5f392 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -40,11 +40,12 @@ Deprecated. Moving to PSDependAction .PARAMETER PSDependAction - Test, Install, or Import the module. Defaults to Install + Test, Install, Import, or Resolve the module. Defaults to Install Test: Return true or false on whether the dependency is in place Install: Install the dependency Import: Import the dependency + Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @{ @@ -117,7 +118,7 @@ param( [switch]$Import, - [ValidateSet('Test', 'Install', 'Import')] + [ValidateSet('Test', 'Install', 'Import', 'Resolve')] [string[]]$PSDependAction = @('Install') ) @@ -220,6 +221,62 @@ if ($Credential) { $Params.add('Credential', $Credential) } +# Resolve: query the repository only, report the selected version and its declared +# dependencies as NuGet ranges, and return before any local checks or installs. +if ($PSDependAction -contains 'Resolve') { + $resolveParams = @{ Name = $Name } + if ($Repository) { $resolveParams.Add('Repository', $Repository) } + if ($Credential) { $resolveParams.Add('Credential', $Credential) } + if ($AllowPrerelease) { $resolveParams.Add('AllowPrerelease', $AllowPrerelease) } + + $available = @(Find-Module @resolveParams -AllVersions -ErrorAction SilentlyContinue) + $candidates = @($available | ForEach-Object { $_.Version.ToString() }) + + $resolvedVersion = $null + if ($Version -eq 'latest') { + foreach ($candidate in $candidates) { + if ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidate -DifferenceVersion $resolvedVersion) -gt 0) { + $resolvedVersion = $candidate + } + } + } + else { + $resolvedVersion = Resolve-VersionInRange -Candidate $candidates -Required $Version + } + + if (-not $resolvedVersion) { + $repositoryLabel = if ($Repository) { $Repository } else { 'the default repositories' } + Write-Error "No version of [$Name] at [$repositoryLabel] satisfies [$Version]" + return + } + + $selected = $available | Where-Object { $_.Version.ToString() -eq $resolvedVersion } | Select-Object -First 1 + + # PowerShellGet reports each dependency as a hashtable with Name and optional + # RequiredVersion / MinimumVersion / MaximumVersion. Map to PSDepend range syntax. + $childDependencies = @{} + foreach ($dep in @($selected.Dependencies)) { + if (-not $dep -or -not $dep['Name']) { continue } + $min = $dep['MinimumVersion'] + $max = $dep['MaximumVersion'] + $childDependencies[$dep['Name']] = + if ($dep['RequiredVersion']) { [string]$dep['RequiredVersion'] } + elseif ($min -and $max) { "[$min,$max]" } + elseif ($min) { "[$min,)" } + elseif ($max) { "(,$max]" } + else { 'latest' } + } + + $canonicalName = if ($selected.Name) { $selected.Name } else { $Name } + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $canonicalName + Version = $resolvedVersion + Dependencies = $childDependencies + } + return +} + # This code works for both install and save scenarios. if ($command -eq 'Save') { $ModuleName = Join-Path $Scope $Name diff --git a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 index 9a32d84..8e9ab11 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -23,11 +23,12 @@ If specified, import the module in the global scope .PARAMETER PSDependAction - Test, Install, or Import the module. Defaults to Install + Test, Install, Import, or Resolve the module. Defaults to Install Test: Return true or false on whether the dependency is in place Install: Install the dependency Import: Import the dependency + Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @@ -63,7 +64,7 @@ param( [switch]$Import, - [ValidateSet('Test', 'Install', 'Import')] + [ValidateSet('Test', 'Install', 'Import', 'Resolve')] [string[]]$PSDependAction = @('Install') ) # Extract data from Dependency @@ -83,6 +84,36 @@ if (-not $Dependency.Source) { $Source = 'https://www.powershellgallery.com/api/v2/' } +$Credential = $Dependency.Credential + +if ($PSDependAction -contains 'Resolve') { + $packages = @(Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential) + $resolvedVersion = $null + if ($Version -eq 'latest') { + $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) + foreach ($package in $stable) { + if (-not $resolvedVersion -or (Compare-Version $package.Version $resolvedVersion) -gt 0) { + $resolvedVersion = $package.Version + } + } + } + else { + $resolvedVersion = Resolve-VersionInRange -Candidate @($packages.Version) -Required $Version + } + if (-not $resolvedVersion) { + Write-Error "No version of [$Name] at [$Source] satisfies [$Version]" + return + } + $resolved = $packages | Where-Object { $_.Version -eq $resolvedVersion } | Select-Object -First 1 + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $Name + Version = $resolvedVersion + Dependencies = ConvertFrom-NugetDependencyString -Dependencies $resolved.Properties.Dependencies + } + return +} + # We use target as a proxy for Scope $Target = $Dependency.Target if (-not $Dependency.Target) { @@ -90,8 +121,6 @@ if (-not $Dependency.Target) { return } -$Credential = $Dependency.Credential - if (-not (Get-Command Nuget -ErrorAction SilentlyContinue)) { if (Test-PlatformSupport -Type 'PSGalleryNuget' -Support 'windows', 'core') { BootStrap-Nuget -NugetPath $NuGetPath diff --git a/PSDepend/PSDependScripts/PSResourceGet.ps1 b/PSDepend/PSDependScripts/PSResourceGet.ps1 index 90672ae..76ffeb4 100644 --- a/PSDepend/PSDependScripts/PSResourceGet.ps1 +++ b/PSDepend/PSDependScripts/PSResourceGet.ps1 @@ -58,12 +58,14 @@ removed in a future release. .PARAMETER PSDependAction - Test, Install, or Import the module. + Test, Install, Import, or Resolve the module. Defaults to Install. Test: Returns $true or $false depending on whether the dependency is present Install: Installs the dependency Import: Imports the dependency + Resolve: Query the source for the highest version satisfying Version and report + its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @{ @@ -149,7 +151,7 @@ param( [switch]$Import, - [ValidateSet('Test', 'Install', 'Import')] + [ValidateSet('Test', 'Install', 'Import', 'Resolve')] [string[]]$PSDependAction = @('Install') ) @@ -263,6 +265,75 @@ foreach ($thisParameter in $params.Keys) { } $params = $tempParams.Clone() +if ($PSDependAction -contains 'Resolve') { + $FindModuleParams = @{ Name = $Name; Version = '*' } + if ($Repository) { + $FindModuleParams.Add('Repository', $Repository) + } + if ($Credential) { + $FindModuleParams.Add('Credential', $Credential) + } + if ($Prerelease) { + $FindModuleParams.Add('Prerelease', $true) + } + + $available = @(Find-PSResource @FindModuleParams -ErrorAction SilentlyContinue) + $candidates = @{} + foreach ($found in $available) { + $candidateVersion = $found.Version.ToString() + if ($found.Prerelease) { + $candidateVersion = "$candidateVersion-$($found.Prerelease)" + } + $candidates[$candidateVersion] = $found + } + + $resolvedVersion = $null + if ($Version -eq 'latest') { + foreach ($candidateVersion in $candidates.Keys) { + if ($null -eq $resolvedVersion -or (Compare-Version -ReferenceVersion $candidateVersion -DifferenceVersion $resolvedVersion) -gt 0) { + $resolvedVersion = $candidateVersion + } + } + } + elseif ($candidates.Count -gt 0) { + $resolvedVersion = Resolve-VersionInRange -Candidate @($candidates.Keys) -Required $Version + } + + if (-not $resolvedVersion) { + Write-Error "No version of [$Name] at [$Repository] satisfies [$Version]" + return + } + + $selected = $candidates[$resolvedVersion] + + # Dependency.VersionRange is a NuGet.Versioning.VersionRange; ToString() yields the + # normalized bracketed form ('[1.0.0, )'), so a nuspec bare '1.0.0' (meaning >= 1.0.0) + # never leaks through as a PSDepend exact version. Unbounded ranges collapse to 'latest'. + $childDependencies = @{} + foreach ($child in @($selected.Dependencies)) { + if (-not $child.Name) { + continue + } + $childRange = 'latest' + if ($null -ne $child.VersionRange) { + $rangeString = $child.VersionRange.ToString() -replace '\s', '' + if ($rangeString -and $rangeString -ne '(,)' -and $rangeString -notmatch '\*') { + $childRange = $rangeString + } + } + $childDependencies[$child.Name] = $childRange + } + + $resolvedName = if ($selected.Name) { $selected.Name } else { $Name } + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $resolvedName + Version = $resolvedVersion + Dependencies = $childDependencies + } + return +} + Add-ToPsModulePathIfRequired -Dependency $Dependency -Action $PSDependAction $Existing = Get-Module -ListAvailable -Name $ModuleName -ErrorAction SilentlyContinue diff --git a/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 b/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 new file mode 100644 index 0000000..aa3baec --- /dev/null +++ b/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 @@ -0,0 +1,65 @@ +function ConvertFrom-NugetDependencyString { + <# + .SYNOPSIS + Convert a NuGet v2 OData Dependencies string into a PSDepend dependency map. + + .DESCRIPTION + NuGet v2 feeds report package dependencies as a single string: + entries are separated by '|', each entry is 'id:versionRange:targetFramework' + (the range and framework may be empty; entries with an empty id are + framework-only group markers such as '::net45' and are skipped). + + Ranges are converted to PSDepend semantics (see adr/0001): + empty -> 'latest' + bracketed/parens -> kept, with internal whitespace removed ('[1.3.3, )' -> '[1.3.3,)') + bare version 1.0.0 -> '[1.0.0,)' (NuGet bare means minimum inclusive; PSDepend bare means exact) + + When the same id appears under several target frameworks, the first + occurrence wins. Null or empty input returns an empty hashtable. + + .PARAMETER Dependencies + The raw Dependencies string from the feed's package metadata. + + .EXAMPLE + ConvertFrom-NugetDependencyString -Dependencies 'Newtonsoft.Json:13.0.1:' + + Returns @{ 'Newtonsoft.Json' = '[13.0.1,)' }. + + .EXAMPLE + ConvertFrom-NugetDependencyString -Dependencies 'git.install:[2.44.0]:|Foo::|::net45' + + Returns @{ 'git.install' = '[2.44.0]'; Foo = 'latest' }. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [AllowNull()] + [AllowEmptyString()] + [string]$Dependencies + ) + + $map = @{} + if ([string]::IsNullOrWhiteSpace($Dependencies)) { + return $map + } + + foreach ($entry in $Dependencies -split '\|') { + $parts = $entry -split ':', 3 + $id = $parts[0].Trim() + if (-not $id -or $map.ContainsKey($id)) { + continue + } + + $range = if ($parts.Count -gt 1) { $parts[1] -replace '\s', '' } else { '' } + if (-not $range) { + $range = 'latest' + } + elseif ($range -notmatch '[\[\](),]') { + $range = "[$range,)" + } + + $map[$id] = $range + } + + $map +} diff --git a/PSDepend/Private/Export-PSDependLock.ps1 b/PSDepend/Private/Export-PSDependLock.ps1 new file mode 100644 index 0000000..bc76069 --- /dev/null +++ b/PSDepend/Private/Export-PSDependLock.ps1 @@ -0,0 +1,32 @@ +function Export-PSDependLock { + <# + .SYNOPSIS + Write a lock object to disk as JSON. + + .DESCRIPTION + Serialises the ordered lock object produced by Resolve-PSDependLock. Keys are + already sorted by the producer so the file diffs cleanly under source + control. The file is written as UTF-8 without a BOM. + + .PARAMETER Lock + The lock object from Resolve-PSDependLock. + + .PARAMETER Path + Destination path, normally from Get-PSDependLockPath. + + .EXAMPLE + Export-PSDependLock -Lock $lock -Path .\requirements.lock.json + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [System.Collections.IDictionary]$Lock, + + [Parameter(Mandatory)] + [string]$Path + ) + + $json = ConvertTo-Json -InputObject $Lock -Depth 10 + $utf8 = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $json + [Environment]::NewLine, $utf8) +} diff --git a/PSDepend/Private/Find-NodeModule.ps1 b/PSDepend/Private/Find-NodeModule.ps1 new file mode 100644 index 0000000..e7dfa2e --- /dev/null +++ b/PSDepend/Private/Find-NodeModule.ps1 @@ -0,0 +1,47 @@ +function Find-NodeModule { + <# + .SYNOPSIS + Query the npm registry for the published versions of a package. + + .DESCRIPTION + Runs `npm view [@] version --json` and returns the matching + version strings. npm prints a single JSON string when one version matches and a + JSON array when several do; both are normalised to [string[]]. + + Writes an error and returns nothing when npm is not on PATH. + + .PARAMETER PackageName + The npm package name. + + .PARAMETER Version + Optional npm version or range spec. Empty or 'latest' lists every published version. + + .EXAMPLE + Find-NodeModule -PackageName 'left-pad' -Version '1.3.0' + + Returns '1.3.0' when that version is published. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [string]$PackageName, + [string]$Version + ) + + if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Error "npm was not found on PATH; cannot query versions for [$PackageName]" + return + } + + if ([string]::IsNullOrEmpty($Version) -or $Version -eq 'latest') { + $json = npm view $PackageName version --json + } else { + $json = npm view "$PackageName@$Version" version --json + } + + if ([string]::IsNullOrWhiteSpace(($json -join ''))) { + return + } + + [string[]]@(($json -join "`n") | ConvertFrom-Json) +} diff --git a/PSDepend/Private/Get-PSDependLockPath.ps1 b/PSDepend/Private/Get-PSDependLockPath.ps1 new file mode 100644 index 0000000..ee20b7b --- /dev/null +++ b/PSDepend/Private/Get-PSDependLockPath.ps1 @@ -0,0 +1,27 @@ +function Get-PSDependLockPath { + <# + .SYNOPSIS + Return the lock file path that belongs to a DependencyFile. + + .DESCRIPTION + The lock lives next to its DependencyFile with the .psd1 extension replaced + by .lock.json: requirements.psd1 -> requirements.lock.json, + build.depend.psd1 -> build.depend.lock.json. + + .PARAMETER DependencyFile + Full path to the DependencyFile. + + .EXAMPLE + Get-PSDependLockPath -DependencyFile C:\proj\requirements.psd1 + + Returns C:\proj\requirements.lock.json. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string]$DependencyFile + ) + + [System.IO.Path]::ChangeExtension($DependencyFile, '.lock.json') +} diff --git a/PSDepend/Private/Import-PSDependLock.ps1 b/PSDepend/Private/Import-PSDependLock.ps1 new file mode 100644 index 0000000..9ec06ca --- /dev/null +++ b/PSDepend/Private/Import-PSDependLock.ps1 @@ -0,0 +1,52 @@ +function Import-PSDependLock { + <# + .SYNOPSIS + Read a lock file back into the same shape Resolve-PSDependLock produces. + + .DESCRIPTION + Parses the JSON lock and returns an ordered hashtable with lockfileVersion, + dependencies and packages. Throws when the file is not a PSDepend lock or + was written by a newer, unsupported lockfileVersion. + + .PARAMETER Path + Path to the lock file. + + .EXAMPLE + Import-PSDependLock -Path .\requirements.lock.json + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + function ConvertTo-OrderedHashtable { + param($InputObject) + if ($InputObject -is [System.Management.Automation.PSCustomObject]) { + $table = [ordered]@{} + foreach ($property in $InputObject.PSObject.Properties) { + $table[$property.Name] = ConvertTo-OrderedHashtable $property.Value + } + return $table + } + $InputObject + } + + $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop + try { + $parsed = ConvertFrom-Json -InputObject $raw -ErrorAction Stop + } catch { + throw "Lock file [$Path] is not valid JSON: $_" + } + $lock = ConvertTo-OrderedHashtable $parsed + + if (-not $lock.Contains('lockfileVersion') -or -not $lock.Contains('dependencies') -or -not $lock.Contains('packages')) { + throw "Lock file [$Path] is not a PSDepend lock file (missing lockfileVersion, dependencies or packages)" + } + if ($lock.lockfileVersion -ne 1) { + throw "Lock file [$Path] uses lockfileVersion [$($lock.lockfileVersion)]; this version of PSDepend supports lockfileVersion 1. Regenerate it with Update-PSDependLock" + } + + $lock +} diff --git a/PSDepend/Private/Join-VersionRange.ps1 b/PSDepend/Private/Join-VersionRange.ps1 new file mode 100644 index 0000000..aa8d09c --- /dev/null +++ b/PSDepend/Private/Join-VersionRange.ps1 @@ -0,0 +1,114 @@ +function Join-VersionRange { + <# + .SYNOPSIS + Intersect several version constraints into a single NuGet range string. + + .DESCRIPTION + When a lock is resolved, one package can be constrained by several parents + (and by the DependencyFile itself). Each DependencyScript only understands + a single Version string, so the constraints are intersected here first. + + Empty strings and 'latest' impose no constraint. Exact versions must agree + with each other and satisfy every range. Ranges are intersected bound by + bound: the highest lower bound and lowest upper bound win, and when bounds + tie the exclusive one is kept because it is stricter. Equal inclusive bounds + collapse to an exact version. + + Returns 'latest', an exact version, or a NuGet range string. Writes a + non-terminating error and returns nothing when the constraints conflict or + one of them cannot be parsed. + + .PARAMETER Range + The constraints to intersect: 'latest', exact versions, or NuGet ranges. + + .EXAMPLE + Join-VersionRange -Range '[1.0,3.0)', '[2.0,)' + + Returns '[2.0,3.0)'. + + .EXAMPLE + Join-VersionRange -Range '2.5.0', '[2.0,3.0)' + + Returns '2.5.0' because the exact version lies inside the range. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [AllowEmptyString()] + [AllowNull()] + [string[]]$Range + ) + + $constraints = @($Range | Where-Object { -not [string]::IsNullOrEmpty($_) -and $_ -ne 'latest' }) + if ($constraints.Count -eq 0) { + return 'latest' + } + + $parsed = foreach ($constraint in $constraints) { + $bounds = ConvertFrom-VersionRange -Version $constraint + if (-not $bounds) { + return + } + $bounds + } + + $exacts = @($parsed | Where-Object IsExact) + $ranges = @($parsed | Where-Object { -not $_.IsExact }) + + if ($exacts.Count -gt 0) { + $exact = $exacts[0].Exact + foreach ($other in $exacts) { + if (-not (Test-VersionEquality -ReferenceVersion $exact -DifferenceVersion $other.Exact)) { + Write-Error "Version constraints conflict: exact versions [$exact] and [$($other.Exact)] both required" + return + } + } + foreach ($constraint in $constraints) { + if (-not (Test-VersionInRange -Version $exact -Required $constraint)) { + Write-Error "Version constraints conflict: exact version [$exact] does not satisfy [$constraint]" + return + } + } + return $exact + } + + $min = $null + $minInclusive = $true + $max = $null + $maxInclusive = $true + foreach ($bounds in $ranges) { + if ($null -ne $bounds.Min) { + $cmp = if ($null -eq $min) { 1 } else { Compare-Version -ReferenceVersion $bounds.Min -DifferenceVersion $min } + if ($cmp -gt 0) { + $min = $bounds.Min + $minInclusive = $bounds.MinInclusive + } elseif ($cmp -eq 0 -and -not $bounds.MinInclusive) { + $minInclusive = $false + } + } + if ($null -ne $bounds.Max) { + $cmp = if ($null -eq $max) { -1 } else { Compare-Version -ReferenceVersion $bounds.Max -DifferenceVersion $max } + if ($cmp -lt 0) { + $max = $bounds.Max + $maxInclusive = $bounds.MaxInclusive + } elseif ($cmp -eq 0 -and -not $bounds.MaxInclusive) { + $maxInclusive = $false + } + } + } + + if ($null -ne $min -and $null -ne $max) { + $cmp = Compare-Version -ReferenceVersion $min -DifferenceVersion $max + if ($cmp -gt 0 -or ($cmp -eq 0 -and -not ($minInclusive -and $maxInclusive))) { + Write-Error "Version constraints conflict: no version satisfies all of [$($constraints -join '], [')]" + return + } + if ($cmp -eq 0) { + return $min + } + } + + $open = if ($null -ne $min -and $minInclusive) { '[' } else { '(' } + $close = if ($null -ne $max -and $maxInclusive) { ']' } else { ')' } + "$open$min,$max$close" +} diff --git a/PSDepend/Private/Merge-PSDependLock.ps1 b/PSDepend/Private/Merge-PSDependLock.ps1 new file mode 100644 index 0000000..4c848a6 --- /dev/null +++ b/PSDepend/Private/Merge-PSDependLock.ps1 @@ -0,0 +1,150 @@ +function Merge-PSDependLock { + <# + .SYNOPSIS + Apply a lock to the Dependencies parsed from its DependencyFile. + + .DESCRIPTION + First verifies the lock still describes the DependencyFile: every + Dependency in the file must appear in the lock with the same + DependencyType, Name and requested Version, and the lock must not list + Dependencies the file no longer has. Any drift throws, mirroring npm ci, + so a stale lock is never silently installed. + + Then, for each locked Dependency, the requested Version is replaced by the + exact locked version and the locked transitive packages are materialised as + additional Dependency objects (named Name@Version) cloned from the + Dependency that pulled them in, with DependsOn edges so children install + before parents. A package required by several parents is emitted once. + Dependencies whose type could not be locked pass through untouched. + + .PARAMETER Dependency + The Dependencies parsed from one DependencyFile. + + .PARAMETER Lock + The lock object from Import-PSDependLock. + + .PARAMETER LockPath + Path of the lock file, used in error messages. + + .EXAMPLE + Merge-PSDependLock -Dependency $deps -Lock (Import-PSDependLock $lockPath) -LockPath $lockPath + #> + [CmdletBinding()] + [OutputType([PSObject[]])] + param( + [PSTypeName('PSDepend.Dependency')] + [PSObject[]]$Dependency, + + [Parameter(Mandatory)] + [System.Collections.IDictionary]$Lock, + + [Parameter(Mandatory)] + [string]$LockPath + ) + + $dependencyFile = $Dependency[0].DependencyFile + + # Stale check: the lock's view of the DependencyFile must match what was parsed + $problems = New-Object System.Collections.ArrayList + $seen = @{} + foreach ($root in $Dependency) { + $seen[$root.DependencyName] = $true + $name = if ($root.Name) { $root.Name } else { $root.DependencyName } + $requested = if ([string]::IsNullOrEmpty($root.Version)) { 'latest' } else { [string]$root.Version } + if (-not $Lock.dependencies.Contains($root.DependencyName)) { + $null = $problems.Add("[$($root.DependencyName)] is not in the lock") + continue + } + $entry = $Lock.dependencies[$root.DependencyName] + if ($entry.dependencyType -ne $root.DependencyType -or $entry.name -ne $name -or $entry.requested -ne $requested) { + $null = $problems.Add("[$($root.DependencyName)] changed: lock has [$($entry.dependencyType)] [$($entry.name)] [$($entry.requested)], file has [$($root.DependencyType)] [$name] [$requested]") + } + } + foreach ($lockedName in $Lock.dependencies.Keys) { + if (-not $seen.ContainsKey($lockedName)) { + $null = $problems.Add("[$lockedName] is in the lock but not in the DependencyFile") + } + } + if ($problems.Count -gt 0) { + throw "Lock file [$LockPath] is out of date with [$dependencyFile]:`n - $($problems -join "`n - ")`nRun Update-PSDependLock -Path '$dependencyFile' to refresh it, or use -IgnoreLock to resolve without it." + } + + $rootByKey = @{} + foreach ($root in $Dependency) { + $entry = $Lock.dependencies[$root.DependencyName] + if ($entry.Contains('resolved') -and -not $rootByKey.ContainsKey($entry.resolved)) { + $rootByKey[$entry.resolved] = $root + } + } + + $synthesized = [ordered]@{} + + function Get-LockedPackage { + param([string]$Key) + if (-not $Lock.packages.Contains($Key)) { + throw "Lock file [$LockPath] is corrupt: package [$Key] is referenced but not locked. Run Update-PSDependLock -Path '$dependencyFile' to regenerate it." + } + $Lock.packages[$Key] + } + + # Returns the DependencyName that represents $Key, creating a synthesized + # Dependency (and, recursively, its children) when it is not a root. + function Resolve-LockedChild { + param([string]$Key, [PSObject]$Template) + if ($rootByKey.ContainsKey($Key)) { + return $rootByKey[$Key].DependencyName + } + if ($synthesized.Contains($Key)) { + return $synthesized[$Key].DependencyName + } + $package = Get-LockedPackage -Key $Key + $name = $Key.Substring($Key.IndexOf('::') + 2) + $child = [PSCustomObject]@{ + PSTypeName = 'PSDepend.Dependency' + DependencyFile = $Template.DependencyFile + DependencyName = "$name@$($package.version)" + DependencyType = $Template.DependencyType + Name = $name + Version = $package.version + Parameters = $Template.Parameters + Source = $Template.Source + Target = $Template.Target + AddToPath = $Template.AddToPath + Tags = $Template.Tags + DependsOn = $null + PreScripts = $null + PostScripts = $null + Credential = $Template.Credential + PSDependOptions = $Template.PSDependOptions + Raw = $null + } + $synthesized[$Key] = $child + $child.DependsOn = Get-LockedChildList -Package $package -DependencyType $Template.DependencyType -Template $Template + $child.DependencyName + } + + function Get-LockedChildList { + param($Package, [string]$DependencyType, [PSObject]$Template) + $names = foreach ($childName in $Package.dependencies.Keys) { + Resolve-LockedChild -Key "${DependencyType}::$childName" -Template $Template + } + if ($names) { @($names) } else { $null } + } + + foreach ($root in $Dependency) { + $entry = $Lock.dependencies[$root.DependencyName] + if (-not $entry.Contains('resolved')) { + continue + } + $package = Get-LockedPackage -Key $entry.resolved + Write-Verbose "Lock pins [$($root.DependencyName)] to [$($package.version)] (requested [$($entry.requested)])" + $root.Version = $package.version + $children = Get-LockedChildList -Package $package -DependencyType $root.DependencyType -Template $root + if ($children) { + $root.DependsOn = @(@($root.DependsOn) + $children | Where-Object { $_ } | Select-Object -Unique) + } + } + + $Dependency + $synthesized.Values +} diff --git a/PSDepend/Private/Resolve-PSDependLock.ps1 b/PSDepend/Private/Resolve-PSDependLock.ps1 new file mode 100644 index 0000000..9086ea1 --- /dev/null +++ b/PSDepend/Private/Resolve-PSDependLock.ps1 @@ -0,0 +1,231 @@ +function Resolve-PSDependLock { + <# + .SYNOPSIS + Resolve the full dependency graph of one DependencyFile into a lock object. + + .DESCRIPTION + Walks every Dependency whose DependencyType supports the Resolve + PSDependAction, asking the DependencyScript for the highest version that + satisfies the current constraints and for that version's own dependencies. + Children are queued with their constraints; a package that is required by + several parents is resolved once against the intersection of all their + constraints (Join-VersionRange), so the lock holds exactly one version per + DependencyType::Name. When a parent is re-resolved its previous child + constraints are dropped and the loop continues until no node violates a + constraint (a fixpoint), then unreachable nodes are pruned. + + Dependencies whose DependencyType cannot Resolve (or is unsupported on this + platform) are recorded without a resolved package so a later run can still + detect when the DependencyFile changed. + + Returns an ordered hashtable with lockfileVersion, dependencies (one entry + per DependencyName in the file) and packages (one entry per resolved + DependencyType::Name). Throws on unresolvable or conflicting constraints. + + .PARAMETER Dependency + The Dependencies of one DependencyFile, as returned by Get-Dependency -IgnoreLock. + + .PARAMETER PSDependTypePath + PSDependMap.psd1 mapping DependencyTypes to their scripts. + + .EXAMPLE + Resolve-PSDependLock -Dependency (Get-Dependency -Path .\requirements.psd1 -IgnoreLock) + + Returns the lock object for requirements.psd1. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [PSTypeName('PSDepend.Dependency')] + [PSObject[]]$Dependency, + + [string]$PSDependTypePath = $(Join-Path $ModuleRoot PSDependMap.psd1) + ) + + $maxIterations = 10000 + + $types = Get-PSDependType -Path $PSDependTypePath -SkipHelp + $scripts = Get-PSDependScript -Path $PSDependTypePath + $resolvable = @{} + function Test-Resolvable { + param([string]$DependencyType) + if (-not $resolvable.ContainsKey($DependencyType)) { + $type = $types | Where-Object { $_.DependencyType -eq $DependencyType } + $supported = $false + if ($type -and $type.Supported -and $scripts.$DependencyType) { + $actions = Get-Parameter -Command $scripts.$DependencyType | + Where-Object { $_.Name -eq 'PSDependAction' } | + Select-Object -ExpandProperty ValidateSetValues -ErrorAction SilentlyContinue + $supported = $actions -contains 'Resolve' + } + if (-not $supported) { + Write-Verbose "DependencyType [$DependencyType] does not support Resolve on this platform; it will not be locked" + } + $resolvable[$DependencyType] = $supported + } + $resolvable[$DependencyType] + } + + function Get-Requested { + param($Version) + if ([string]::IsNullOrEmpty($Version)) { 'latest' } else { [string]$Version } + } + + $roots = [ordered]@{} + $nodes = @{} # key -> @{ Name; DependencyType; Version; Dependencies; Template; Constraints = @{ source -> range } } + $queue = New-Object System.Collections.Generic.Queue[string] + + foreach ($root in $Dependency) { + $name = if ($root.Name) { $root.Name } else { $root.DependencyName } + $requested = Get-Requested $root.Version + $entry = [ordered]@{ + dependencyType = $root.DependencyType + name = $name + requested = $requested + } + if (Test-Resolvable -DependencyType $root.DependencyType) { + $key = "$($root.DependencyType)::$name" + $entry.resolved = $key + if (-not $nodes.ContainsKey($key)) { + $nodes[$key] = @{ + Name = $name + DependencyType = $root.DependencyType + Version = $null + Dependencies = @{} + Template = $root + Constraints = @{} + } + } + $nodes[$key].Constraints["root:$($root.DependencyName)"] = $requested + $queue.Enqueue($key) + } + $roots[$root.DependencyName] = $entry + } + + $iterations = 0 + while ($queue.Count -gt 0) { + if (++$iterations -gt $maxIterations) { + throw "Dependency resolution did not converge after [$maxIterations] steps; the dependency graph is probably cyclic with conflicting constraints" + } + $key = $queue.Dequeue() + $node = $nodes[$key] + $constraints = @($node.Constraints.Values) + + # A single constraint is passed through verbatim so DependencyTypes with + # their own range syntax (npm semver) still work; several are intersected. + if ($constraints.Count -eq 1) { + $combined = $constraints[0] + } else { + $combined = Join-VersionRange -Range $constraints -ErrorAction SilentlyContinue -ErrorVariable joinError + if (-not $combined) { + $required = ($node.Constraints.GetEnumerator() | ForEach-Object { "$($_.Key) requires [$($_.Value)]" }) -join '; ' + throw "Cannot lock [$key]: $($joinError[0]). $required" + } + } + + if ($node.Version) { + $satisfied = if ($combined -eq 'latest') { $true } else { Test-VersionInRange -Version $node.Version -Required $combined } + if ($satisfied) { + continue + } + Write-Verbose "Re-resolving [$key]: version [$($node.Version)] no longer satisfies [$combined]" + } + + $template = $node.Template + $probe = [PSCustomObject]@{ + PSTypeName = 'PSDepend.Dependency' + DependencyFile = $template.DependencyFile + DependencyName = $node.Name + DependencyType = $node.DependencyType + Name = $node.Name + Version = $combined + Parameters = $template.Parameters + Source = $template.Source + Target = $template.Target + AddToPath = $false + Tags = $template.Tags + DependsOn = $null + PreScripts = $null + PostScripts = $null + Credential = $template.Credential + PSDependOptions = $template.PSDependOptions + Raw = $null + } + + Write-Verbose "Resolving [$key] with constraint [$combined]" + try { + $resolved = @(Invoke-DependencyScript -Dependency $probe -PSDependAction Resolve -PSDependTypePath $PSDependTypePath -ErrorAction Stop) + } catch { + throw "Cannot lock [$key] with constraint [$combined]: $_" + } + $resolved = @($resolved | Where-Object { $_.PSObject.TypeNames -contains 'PSDepend.ResolvedDependency' }) + if ($resolved.Count -ne 1 -or [string]::IsNullOrEmpty($resolved[0].Version)) { + throw "Cannot lock [$key] with constraint [$combined]: the [$($node.DependencyType)] DependencyScript did not return a resolved version" + } + $result = $resolved[0] + + # Drop the constraints this node imposed on its previous children + foreach ($childKey in @($nodes.Keys)) { + $null = $nodes[$childKey].Constraints.Remove("node:$key") + } + + $node.Version = [string]$result.Version + $node.Dependencies = @{} + if ($result.Dependencies) { + foreach ($childName in $result.Dependencies.Keys) { + $node.Dependencies[$childName] = Get-Requested $result.Dependencies[$childName] + } + } + Write-Verbose "Locked [$key] at [$($node.Version)] with [$($node.Dependencies.Count)] dependencies" + + foreach ($childName in $node.Dependencies.Keys) { + $childKey = "$($node.DependencyType)::$childName" + if (-not $nodes.ContainsKey($childKey)) { + $nodes[$childKey] = @{ + Name = $childName + DependencyType = $node.DependencyType + Version = $null + Dependencies = @{} + Template = $template + Constraints = @{} + } + } + $nodes[$childKey].Constraints["node:$key"] = $node.Dependencies[$childName] + $queue.Enqueue($childKey) + } + } + + # Keep only packages still reachable from the DependencyFile + $reachable = @{} + $walk = New-Object System.Collections.Generic.Queue[string] + foreach ($entry in $roots.Values) { + if ($entry.Contains('resolved')) { $walk.Enqueue($entry.resolved) } + } + while ($walk.Count -gt 0) { + $key = $walk.Dequeue() + if ($reachable.ContainsKey($key)) { continue } + $reachable[$key] = $true + foreach ($childName in $nodes[$key].Dependencies.Keys) { + $walk.Enqueue("$($nodes[$key].DependencyType)::$childName") + } + } + + $packages = [ordered]@{} + foreach ($key in ($reachable.Keys | Sort-Object)) { + $node = $nodes[$key] + $dependencies = [ordered]@{} + foreach ($childName in ($node.Dependencies.Keys | Sort-Object)) { + $dependencies[$childName] = $node.Dependencies[$childName] + } + $packages[$key] = [ordered]@{ + version = $node.Version + dependencies = $dependencies + } + } + + [ordered]@{ + lockfileVersion = 1 + dependencies = $roots + packages = $packages + } +} diff --git a/PSDepend/Public/Get-Dependency.ps1 b/PSDepend/Public/Get-Dependency.ps1 index 854c39c..8c17737 100644 --- a/PSDepend/Public/Get-Dependency.ps1 +++ b/PSDepend/Public/Get-Dependency.ps1 @@ -121,6 +121,14 @@ function Get-Dependency { AnotherPrivatePackage = $morePrivateCredentials } + .PARAMETER IgnoreLock + Skip any .lock.json next to a dependency file and return the dependencies exactly as declared. + + By default, when a lock written by Update-PSDependLock exists, each locked dependency's Version is + replaced with the locked version and locked transitive packages are returned as additional + dependencies (named Name@Version) that the declaring dependency DependsOn. A lock that no longer + matches its dependency file is an error. + .EXAMPLE Get-Dependency -Path C:\requirements.psd1 @@ -144,7 +152,10 @@ function Get-Dependency { [parameter(ParameterSetName = 'File')] [parameter(ParameterSetName = 'Hashtable')] - [hashtable]$Credentials + [hashtable]$Credentials, + + [parameter(ParameterSetName = 'File')] + [switch]$IgnoreLock ) # Helper to pick from global PSDependOptions, or return a default @@ -424,7 +435,16 @@ function Get-Dependency { $File = Split-Path $DependencyFile -Leaf $Dependencies = Import-LocalizedData -BaseDirectory $Base -FileName $File - Parse-Dependency -ParamSet $PSCmdlet.ParameterSetName + $FileDependencies = @( Parse-Dependency -ParamSet $PSCmdlet.ParameterSetName ) + + $LockPath = Get-PSDependLockPath -DependencyFile $DependencyFile + if (-not $IgnoreLock -and $FileDependencies.Count -gt 0 -and (Test-Path -LiteralPath $LockPath -PathType Leaf)) { + Write-Verbose "Applying lock [$LockPath] to [$DependencyFile]" + $Lock = Import-PSDependLock -Path $LockPath + Merge-PSDependLock -Dependency $FileDependencies -Lock $Lock -LockPath $LockPath + } else { + $FileDependencies + } } } } elseif ($PSCmdlet.ParameterSetName -eq 'Hashtable') { diff --git a/PSDepend/Public/Invoke-DependencyScript.ps1 b/PSDepend/Public/Invoke-DependencyScript.ps1 index 18afa68..5bca77f 100644 --- a/PSDepend/Public/Invoke-DependencyScript.ps1 +++ b/PSDepend/Public/Invoke-DependencyScript.ps1 @@ -22,7 +22,7 @@ .PARAMETER PSDependAction PSDependAction to run. Test, Install, and Import are the most common. - Test can only be run by itself. + Test can only be run by itself. Resolve (used by Update-PSDependLock) can only be run by itself. .PARAMETER Quiet If PSDependAction is Test, and Quiet is specified, we return $true or $false based on whether a dependency exists @@ -55,13 +55,13 @@ begin { # This script reads a depend.psd1, installs dependencies as defined Write-Verbose "Running Invoke-DependencyScript with ParameterSetName '$($PSCmdlet.ParameterSetName)' and params: $($PSBoundParameters | Out-String)" - $PSDependTypes = Get-PSDependType -SkipHelp + $PSDependTypes = Get-PSDependType -Path $PSDependTypePath -SkipHelp } process { Write-Verbose "Dependencies:`n$($Dependency | Select-Object -Property * | Out-String)" #Get definitions, and dependencies in this particular psd1 - $DependencyDefs = Get-PSDependScript + $DependencyDefs = Get-PSDependScript -Path $PSDependTypePath $TheseDependencyTypes = @( $Dependency.DependencyType | Sort-Object -Unique ) #Build up hash, we call each DependencyType script for applicable dependencies @@ -109,6 +109,11 @@ $PSDependActions = $PSDependActions | Where-Object { $_ -ne 'Test' } } + if ($PSDependActions -contains 'Resolve' -and $PSDependActions.Count -gt 1) { + Write-Error "Removing [Resolve] from PSDependActions. The Resolve action must run on its own." + $PSDependActions = $PSDependActions | Where-Object { $_ -ne 'Resolve' } + } + foreach ($ThisDependency in $TheseDependencies) { #Parameters for dependency types. Only accept valid params... if ($ThisDependency.Parameters.keys.count -gt 0) { @@ -121,7 +126,7 @@ } } - if ($ThisDependency.Parameters.Import -and $PSDependActions -notcontains 'Test') { + if ($ThisDependency.Parameters.Import -and $PSDependActions -notcontains 'Test' -and $PSDependActions -notcontains 'Resolve') { $PSDependActions += 'Import' $PSDependActions = $PSDependActions | Sort-Object -Unique } diff --git a/PSDepend/Public/Invoke-PSDepend.ps1 b/PSDepend/Public/Invoke-PSDepend.ps1 index 1e2feeb..06134cd 100644 --- a/PSDepend/Public/Invoke-PSDepend.ps1 +++ b/PSDepend/Public/Invoke-PSDepend.ps1 @@ -81,6 +81,13 @@ function Invoke-PSDepend { AnotherPrivatePackage = $morePrivateCredentials } + .PARAMETER IgnoreLock + Skip any .lock.json next to a dependency file and resolve versions as declared. + + By default, a lock written by Update-PSDependLock pins each dependency to its locked version + and installs locked transitive packages first. A lock that no longer matches its dependency + file is an error; run Update-PSDependLock to refresh it. + .EXAMPLE Invoke-PSDepend @@ -156,7 +163,11 @@ function Invoke-PSDepend { [parameter(ParameterSetName = 'installimport-file')] [parameter(ParameterSetName = 'installimport-hashtable')] - [hashtable]$Credentials + [hashtable]$Credentials, + + [parameter(ParameterSetName = 'installimport-file')] + [parameter(ParameterSetName = 'test-file')] + [switch]$IgnoreLock ) Begin { # Build parameters @@ -193,6 +204,9 @@ function Invoke-PSDepend { } } $GetPSDependParams.add('Path', $DependencyFiles) + if ($IgnoreLock) { + $GetPSDependParams.Add('IgnoreLock', $true) + } } elseif ($PSCmdlet.ParameterSetName -like '*-hashtable') { $GetPSDependParams.add('InputObject', $InputObject) diff --git a/PSDepend/Public/Update-PSDependLock.ps1 b/PSDepend/Public/Update-PSDependLock.ps1 new file mode 100644 index 0000000..4507e83 --- /dev/null +++ b/PSDepend/Public/Update-PSDependLock.ps1 @@ -0,0 +1,110 @@ +function Update-PSDependLock { + <# + .SYNOPSIS + Resolve a DependencyFile's full dependency graph and write a lock file + + .DESCRIPTION + Resolve a DependencyFile's full dependency graph and write a lock file + + Works like npm's package-lock.json: every dependency whose DependencyType + supports the Resolve action is resolved to the highest version that + satisfies its Version (exact, 'latest', or a NuGet range), its own + dependencies are resolved the same way recursively, and the result is + written next to the DependencyFile as .lock.json + (requirements.psd1 -> requirements.lock.json). + + A package required by several dependencies is locked to one version that + satisfies all of their constraints; conflicting constraints fail the update. + + Once a lock exists, Invoke-PSDepend and Get-Dependency use it automatically: + each dependency installs at its locked version and locked transitive + packages install first. If the DependencyFile changes, the lock is reported + as out of date until you run Update-PSDependLock again (or pass -IgnoreLock). + + DependencyTypes without a Resolve action (Git, GitHub, FileDownload, ...) are + recorded in the lock so drift is detected, but install exactly as before. + + See Get-Help about_PSDepend for more information. + + .PARAMETER Path + Path to a specific depend.psd1 file, or to a folder that we recursively search for *.depend.psd1 and requirements.psd1 files + + Defaults to the current path + + .PARAMETER Recurse + If path is a folder, whether to recursively search for *.depend.psd1 and requirements.psd1 files under that folder + + Defaults to $True + + .PARAMETER PSDependTypePath + Specify a PSDependMap.psd1 file that maps DependencyTypes to their scripts. + + This defaults to the PSDependMap.psd1 in the PSDepend module folder + + .PARAMETER Credentials + Specifies a hashtable of PSCredentials to use for each dependency that is served from a private feed. The key of the hashtable must match the Credential property value in the dependency. + + .PARAMETER PassThru + Return the path of each lock file that was written + + .EXAMPLE + Update-PSDependLock -Path .\requirements.psd1 + + # Resolve every dependency (and their dependencies) in requirements.psd1 and write requirements.lock.json + + .EXAMPLE + Update-PSDependLock -Path C:\Project -Recurse $false + + # Write a lock for each *.depend.psd1 and requirements.psd1 directly under C:\Project + + .LINK + https://github.com/PowerShellOrg/PSDepend + #> + [CmdletBinding(SupportsShouldProcess = $True)] + [OutputType([string])] + param( + [validatescript( { Test-Path -Path $_ -ErrorAction Stop })] + [parameter( Position = 0, + ValueFromPipeline = $True, + ValueFromPipelineByPropertyName = $True)] + [string[]]$Path = '.', + + [bool]$Recurse = $True, + + [validatescript( { Test-Path -Path $_ -PathType Leaf -ErrorAction Stop })] + [string]$PSDependTypePath = $(Join-Path $ModuleRoot PSDependMap.psd1), + + [hashtable]$Credentials, + + [switch]$PassThru + ) + process { + foreach ($PathItem in $Path) { + $DependencyFiles = @( Resolve-DependScripts -Path $PathItem -Recurse $Recurse ) + if ($DependencyFiles.Count -eq 0) { + Write-Warning "No *.depend.psd1 or requirements.psd1 files found under [$PathItem]" + continue + } + + foreach ($DependencyFile in $DependencyFiles) { + $GetParams = @{ Path = $DependencyFile; IgnoreLock = $true } + if ($null -ne $Credentials) { + $GetParams.Add('Credentials', $Credentials) + } + $Dependencies = @( Get-Dependency @GetParams -ErrorAction Stop | Where-Object { $_ } ) + $LockPath = Get-PSDependLockPath -DependencyFile $DependencyFile + + Write-Verbose "Resolving [$($Dependencies.Count)] dependencies from [$DependencyFile]" + $Lock = Resolve-PSDependLock -Dependency $Dependencies -PSDependTypePath $PSDependTypePath + + if ($PSCmdlet.ShouldProcess($LockPath, "Write lock for '$DependencyFile'")) { + Export-PSDependLock -Lock $Lock -Path $LockPath + Write-Verbose "Wrote lock with [$($Lock.packages.Count)] packages to [$LockPath]" + if ($PassThru) { + $LockPath + } + } + } + } + } +} diff --git a/PSDepend/en-US/about_PSDepend.help.txt b/PSDepend/en-US/about_PSDepend.help.txt index 494ba6a..4bad757 100644 --- a/PSDepend/en-US/about_PSDepend.help.txt +++ b/PSDepend/en-US/about_PSDepend.help.txt @@ -105,6 +105,33 @@ DETAILED DESCRIPTION Position? 2 Default value PSGallery + Locking dependencies + ==================== + Like npm's package-lock.json, PSDepend can pin every dependency, and the + dependencies they pull in, to exact versions: + + Update-PSDependLock -Path .\requirements.psd1 # writes requirements.lock.json + Invoke-PSDepend -Path .\requirements.psd1 # installs the locked versions + + Update-PSDependLock asks each DependencyType that supports the Resolve + action (PSGalleryModule, PSResourceGet, PSGalleryNuget, Nuget, Chocolatey, + Npm) for the highest version satisfying the declared Version and walks that + package's own dependencies the same way. One version is locked per + DependencyType::Name; conflicting constraints fail the update. Npm pins only + the declared package; npm's package-lock.json governs its subtree. + + When a lock exists next to a dependency file, Invoke-PSDepend and + Get-Dependency use it automatically: dependencies install at their locked + version and locked transitive packages (returned as Name@Version + dependencies) install first. A lock that no longer matches its dependency + file is an error until Update-PSDependLock is run again; -IgnoreLock + resolves without it. DependencyTypes without Resolve install as declared. + + A DependencyScript opts in by accepting 'Resolve' in PSDependAction and, + for that action, emitting one PSDepend.ResolvedDependency object + (Name, exact Version, Dependencies hashtable of Name -> NuGet range or + 'latest') after querying its source, without installing anything. + Extending PSDepend ================== PSDepend is somewhat extensible. To add a new dependency type: diff --git a/README.md b/README.md index ff6b949..77505e2 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,21 @@ Invoke-PSDepend -Path C:\requirements.psd1 -Credentials @{ 'my_gallery' = $creds The credential key must match between the dependency definition and the hashtable passed to `-Credentials`. +## Locking Dependencies + +Like npm's `package-lock.json`, PSDepend can pin every dependency — and the dependencies *they* pull in — to exact versions so that every machine installs the same thing: + +```powershell +Update-PSDependLock -Path .\requirements.psd1 # writes requirements.lock.json +Invoke-PSDepend -Path .\requirements.psd1 # installs the locked versions +``` + +`Update-PSDependLock` asks each dependency type that supports the `Resolve` action (`PSGalleryModule`, `PSResourceGet`, `PSGalleryNuget`, `Nuget`, `Chocolatey`, `Npm`) for the highest version that satisfies the declared `Version` (exact, `latest`, or a NuGet range) and walks that package's own dependencies the same way. A package required by several dependencies is locked to one version that satisfies all of their constraints; conflicting constraints fail the update. `Npm` pins only the declared package — npm's own `package-lock.json` governs its subtree. + +Once a lock exists next to a dependency file, `Invoke-PSDepend` and `Get-Dependency` use it automatically: each dependency installs at its locked version, and locked transitive packages install first. If the dependency file changes (a version constraint edited, a dependency added or removed), the lock is reported as out of date until you run `Update-PSDependLock` again. Pass `-IgnoreLock` to resolve without it. Commit the `.lock.json` alongside the dependency file. + +Dependency types without a `Resolve` action (`Git`, `GitHub`, `FileDownload`, ...) are recorded in the lock for drift detection but install exactly as declared. + ## Getting Help Each dependency type may handle standard properties differently and expose its own parameters. Use `Get-PSDependType` to see what is available: diff --git a/Tests/Chocolatey.Type.Tests.ps1 b/Tests/Chocolatey.Type.Tests.ps1 index 6805d4e..7c3d5e5 100644 --- a/Tests/Chocolatey.Type.Tests.ps1 +++ b/Tests/Chocolatey.Type.Tests.ps1 @@ -150,3 +150,104 @@ Describe 'Chocolatey script' -Tag 'WindowsOnly' -Skip:$SkipUnsupported { } } } + +# Resolve queries the NuGet v2 feed directly and never needs choco.exe, so it runs on every platform. +Describe 'Chocolatey script Resolve' { + + BeforeAll { + $script:ScriptPath = Join-Path $env:BHProjectPath 'PSDepend/PSDependScripts/Chocolatey.ps1' + InModuleScope PSDepend { + Mock Invoke-ExternalCommand { } + Mock Find-NugetPackage { + foreach ($entry in @( + @{ Version = '2.44.0'; Dependencies = 'git.install:[2.44.0]:|chocolatey-core.extension:[1.3.3, ):|:'; IsPrerelease = 'false' }, + @{ Version = '2.45.0'; Dependencies = 'git.install:[2.45.0]:'; IsPrerelease = 'false' }, + @{ Version = '2.46.0-beta1'; Dependencies = ''; IsPrerelease = 'true' }, + @{ Version = '3.0.0'; Dependencies = 'git.install:3.0.0:|::'; IsPrerelease = 'false' } + )) { + [PSCustomObject]@{ + Name = 'git' + Version = $entry.Version + Properties = [PSCustomObject]@{ + Dependencies = $entry.Dependencies + IsPrerelease = $entry.IsPrerelease + } + } + } + } + } + } + + Context 'PSDependAction = Resolve' { + + It 'latest picks the highest stable version and skips prerelease' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version 'latest' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.PSTypeNames | Should -Contain 'PSDepend.ResolvedDependency' + $result.Name | Should -Be 'git' + $result.Version | Should -Be '3.0.0' + } + + It 'range picks the highest stable in-range version, skipping prerelease' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0,3.0.0)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '2.45.0' + } + + It 'converts the feed Dependencies string to a NuGet-range map' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '2.44.0' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '2.44.0' + $result.Dependencies | Should -BeOfType [hashtable] + $result.Dependencies.Count | Should -Be 2 + $result.Dependencies['git.install'] | Should -Be '[2.44.0]' + $result.Dependencies['chocolatey-core.extension'] | Should -Be '[1.3.3,)' + } + + It 'converts a bare dependency version to a minimum-inclusive range' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '3.0.0' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Dependencies.Count | Should -Be 1 + $result.Dependencies['git.install'] | Should -Be '[3.0.0,)' + } + + It 'writes an error and emits nothing when no version satisfies' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[4.0.0,)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err + $err | Should -Not -BeNullOrEmpty + $err[0].ToString() | Should -Match 'No version of \[git\] at \[https://community\.chocolatey\.org/api/v2/\] satisfies \[\[4\.0\.0,\)\]' + } + $result | Should -BeNullOrEmpty + } + + It 'writes an error and emits nothing when Source is not a feed URL' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Source 'C:\LocalFeed' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err + $err[0].ToString() | Should -Match 'NuGet v2 feed URL' + } + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Find-NugetPackage -ModuleName PSDepend -Times 0 -Exactly + } + + It 'never invokes choco during Resolve' { + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version 'latest' + InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 -Exactly + Should -Invoke -CommandName Find-NugetPackage -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $PackageSourceUrl -eq 'https://community.chocolatey.org/api/v2/' + } + } + } +} diff --git a/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 b/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 new file mode 100644 index 0000000..2422c46 --- /dev/null +++ b/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 @@ -0,0 +1,98 @@ +#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-NugetDependencyString' { + + Context 'Empty input' { + + It 'Returns an empty hashtable for null' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies $null + $r | Should -BeOfType [hashtable] + $r.Count | Should -Be 0 + } + } + + It 'Returns an empty hashtable for an empty string' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies '' + $r | Should -BeOfType [hashtable] + $r.Count | Should -Be 0 + } + } + } + + Context 'Range conversion' { + + It 'Converts a bare version to a minimum-inclusive range' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'Newtonsoft.Json:13.0.1:' + $r.Count | Should -Be 1 + $r['Newtonsoft.Json'] | Should -Be '[13.0.1,)' + } + } + + It 'Maps an empty range to latest' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'Foo::' + $r['Foo'] | Should -Be 'latest' + } + } + + It 'Keeps a bracketed range and strips internal whitespace' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'chocolatey-core.extension:[1.3.3, ):' + $r['chocolatey-core.extension'] | Should -Be '[1.3.3,)' + } + } + + It 'Keeps a bracketed exact version' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'git.install:[2.44.0]:' + $r['git.install'] | Should -Be '[2.44.0]' + } + } + + It 'Keeps an upper-bound-only range' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'Bar:(,3.0]:net45' + $r['Bar'] | Should -Be '(,3.0]' + } + } + } + + Context 'Entries and frameworks' { + + It 'Parses a mixed multi-entry string and skips framework-only groups' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'git.install:[2.44.0]:|chocolatey-core.extension:[1.3.3, ):|Foo::|::net45' + $r.Count | Should -Be 3 + $r['git.install'] | Should -Be '[2.44.0]' + $r['chocolatey-core.extension'] | Should -Be '[1.3.3,)' + $r['Foo'] | Should -Be 'latest' + } + } + + It 'Keeps the first occurrence when an id appears under several frameworks' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'Foo:1.0.0:net45|Foo:2.0.0:netstandard2.0' + $r.Count | Should -Be 1 + $r['Foo'] | Should -Be '[1.0.0,)' + } + } + + It 'Handles an entry with no framework segment' { + InModuleScope PSDepend { + $r = ConvertFrom-NugetDependencyString -Dependencies 'Foo:1.0.0' + $r['Foo'] | Should -Be '[1.0.0,)' + } + } + } +} diff --git a/Tests/Join-VersionRange.Tests.ps1 b/Tests/Join-VersionRange.Tests.ps1 new file mode 100644 index 0000000..4cdc2bc --- /dev/null +++ b/Tests/Join-VersionRange.Tests.ps1 @@ -0,0 +1,100 @@ +#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 'Join-VersionRange' { + + Context 'Unconstrained input' { + It 'Returns latest when every constraint is empty or latest' { + InModuleScope PSDepend { + Join-VersionRange -Range 'latest', '', $null | Should -Be 'latest' + } + } + + It 'Ignores latest alongside a real range' { + InModuleScope PSDepend { + Join-VersionRange -Range 'latest', '[1.0,2.0)' | Should -Be '[1.0,2.0)' + } + } + } + + Context 'Intersection of ranges' { + It 'Takes the tightest lower and upper bounds' { + InModuleScope PSDepend { + Join-VersionRange -Range '[1.0,3.0)', '[2.0,)' | Should -Be '[2.0,3.0)' + } + } + + It 'Prefers the exclusive bound when bounds are equal' { + InModuleScope PSDepend { + Join-VersionRange -Range '[1.0,3.0]', '(1.0,3.0)' | Should -Be '(1.0,3.0)' + } + } + + It 'Collapses equal inclusive bounds to an exact version' { + InModuleScope PSDepend { + Join-VersionRange -Range '[2.0,)', '(,2.0]' | Should -Be '2.0' + } + } + + It 'Keeps an open upper bound when no constraint caps it' { + InModuleScope PSDepend { + Join-VersionRange -Range '[1.0,)', '[1.5,)' | Should -Be '[1.5,)' + } + } + } + + Context 'Exact versions' { + It 'Returns the exact version when it lies inside every range' { + InModuleScope PSDepend { + Join-VersionRange -Range '2.5.0', '[2.0,3.0)' | Should -Be '2.5.0' + } + } + + It 'Accepts two equal exact versions' { + InModuleScope PSDepend { + Join-VersionRange -Range '2.5.0', '2.5.0' | Should -Be '2.5.0' + } + } + } + + Context 'Conflicts' { + It 'Errors when an exact version falls outside a range' { + InModuleScope PSDepend { + $result = Join-VersionRange -Range '3.5.0', '[2.0,3.0)' -ErrorAction SilentlyContinue -ErrorVariable err + $result | Should -BeNullOrEmpty + $err[0].ToString() | Should -Match 'conflict' + } + } + + It 'Errors when two exact versions differ' { + InModuleScope PSDepend { + $result = Join-VersionRange -Range '1.0.0', '1.0.1' -ErrorAction SilentlyContinue -ErrorVariable err + $result | Should -BeNullOrEmpty + $err | Should -Not -BeNullOrEmpty + } + } + + It 'Errors when ranges do not overlap' { + InModuleScope PSDepend { + $result = Join-VersionRange -Range '[1.0,2.0)', '[2.0,3.0)' -ErrorAction SilentlyContinue -ErrorVariable err + $result | Should -BeNullOrEmpty + $err | Should -Not -BeNullOrEmpty + } + } + + It 'Errors when equal bounds meet with an exclusive side' { + InModuleScope PSDepend { + $result = Join-VersionRange -Range '[1.0,2.0]', '(2.0,3.0)' -ErrorAction SilentlyContinue -ErrorVariable err + $result | Should -BeNullOrEmpty + $err | Should -Not -BeNullOrEmpty + } + } + } +} diff --git a/Tests/Npm.Type.Tests.ps1 b/Tests/Npm.Type.Tests.ps1 index 69a5ec1..3881b90 100644 --- a/Tests/Npm.Type.Tests.ps1 +++ b/Tests/Npm.Type.Tests.ps1 @@ -61,4 +61,47 @@ Describe 'Npm script' { } $result | Should -Be $true } + + Context 'PSDependAction = Resolve' { + It 'Picks the highest version when npm returns several' { + InModuleScope PSDepend { + Mock Find-NodeModule { [string[]]@('0.1.0', '0.3.2', '0.2.9') } + } + $dep = New-PSDependFixture -DependencyName 'left-pad' -DependencyType 'Npm' -Version '[0.1.0,0.4.0)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Name | Should -Be 'left-pad' + $result.Version | Should -Be '0.3.2' + $result.Dependencies.Count | Should -Be 0 + Should -Invoke -CommandName Find-NodeModule -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { + $PackageName -eq 'left-pad' -and $Version -eq '[0.1.0,0.4.0)' + } + } + + It 'Returns the single version npm reports for latest' { + InModuleScope PSDepend { + Mock Find-NodeModule { [string[]]@('1.3.0') } + } + $dep = New-PSDependFixture -DependencyName 'left-pad' -DependencyType 'Npm' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + @($result).Count | Should -Be 1 + $result.Version | Should -Be '1.3.0' + } + + It 'Writes an error and emits nothing when npm returns no versions' { + InModuleScope PSDepend { + Mock Find-NodeModule { } + } + $dep = New-PSDependFixture -DependencyName 'left-pad' -DependencyType 'Npm' -Version '9.9.9' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err + $err | Should -Not -BeNullOrEmpty + } + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Install-NodeModule -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/Nuget.Type.Tests.ps1 b/Tests/Nuget.Type.Tests.ps1 index 1645a3a..badaf90 100644 --- a/Tests/Nuget.Type.Tests.ps1 +++ b/Tests/Nuget.Type.Tests.ps1 @@ -93,4 +93,64 @@ Describe 'Nuget script' { Should -Invoke -CommandName BootStrap-Nuget -ModuleName PSDepend -Times 0 } } + + Context 'PSDependAction = Resolve' { + BeforeAll { + InModuleScope PSDepend { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Nuget' } + Mock BootStrap-Nuget { } + Mock Find-NugetPackage { + @( + [PSCustomObject]@{ Version = '1.9.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = '' } } + [PSCustomObject]@{ Version = '2.5.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'System.Memory:4.5.4:net45|Foo::|::netstandard2.0' } } + [PSCustomObject]@{ Version = '3.0.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'System.Memory:[4.5.4, ):' } } + [PSCustomObject]@{ Version = '3.1.0-beta1'; Properties = @{ IsPrerelease = 'true'; Dependencies = '' } } + ) + } + } + } + + It 'Resolves a range to the highest in-range version without a Target or nuget.exe' { + $dep = New-PSDependFixture -DependencyName 'Newtonsoft.Json' -DependencyType 'Nuget' -Version '[2.0.0,3.0.0)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Name | Should -Be 'Newtonsoft.Json' + $result.Version | Should -Be '2.5.0' + $result.Dependencies['System.Memory'] | Should -Be '[4.5.4,)' + $result.Dependencies['Foo'] | Should -Be 'latest' + $result.Dependencies.Count | Should -Be 2 + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName BootStrap-Nuget -ModuleName PSDepend -Times 0 + } + + It 'Resolves latest to the highest stable version, skipping prerelease' { + $dep = New-PSDependFixture -DependencyName 'Newtonsoft.Json' -DependencyType 'Nuget' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '3.0.0' + $result.Dependencies['System.Memory'] | Should -Be '[4.5.4,)' + } + + It 'Uses the Name parameter override as the package id' { + $dep = New-PSDependFixture -DependencyName 'Portable.BouncyCastle' -DependencyType 'Nuget' -Name 'BouncyCastle.Crypto' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Name | Should -Be 'BouncyCastle.Crypto' + Should -Invoke -CommandName Find-NugetPackage -ModuleName PSDepend -Times 1 -ParameterFilter { $Name -eq 'BouncyCastle.Crypto' } + } + + It 'Errors with no output when nothing satisfies the range' { + $dep = New-PSDependFixture -DependencyName 'Newtonsoft.Json' -DependencyType 'Nuget' -Version '[5.0.0,)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable e + $e.Count | Should -Be 1 + $e[0] | Should -Match 'No version of \[Newtonsoft.Json\]' + } + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/PSDependLock.Tests.ps1 b/Tests/PSDependLock.Tests.ps1 new file mode 100644 index 0000000..9d7ad82 --- /dev/null +++ b/Tests/PSDependLock.Tests.ps1 @@ -0,0 +1,184 @@ +#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 + + # A map that exposes the fake Resolve-capable type alongside Noop (which cannot Resolve) + $fakeScript = (Resolve-Path (Join-Path $PSScriptRoot 'Shared/FakeResolver.ps1')).Path + $script:MapPath = Join-Path $TestDrive 'Lock.PSDependMap.psd1' + @" +@{ + FakeResolver = @{ + Script = '$fakeScript' + Description = 'Static graph for lock tests' + Supports = 'windows', 'core', 'macos', 'linux' + } + Noop = @{ + Script = 'Noop.ps1' + Description = 'Noop' + Supports = 'windows', 'core', 'macos', 'linux' + } +} +"@ | Set-Content -Path $script:MapPath + + function Initialize-LockProject { + param([string]$Name, [string]$Body) + $dir = Join-Path $TestDrive $Name + $null = New-Item -ItemType Directory -Path $dir -Force + $file = Join-Path $dir 'requirements.psd1' + Set-Content -Path $file -Value $Body + $file + } + + $script:AppBody = @' +@{ + App = @{ + DependencyType = 'FakeResolver' + Version = '[1.0,2.0)' + Target = '$DependencyFolder/target' + } + Plain = @{ + DependencyType = 'Noop' + Version = 'latest' + } +} +'@ +} + +Describe 'Update-PSDependLock' { + + It 'Resolves the whole graph to one version per package, honouring constraints from every parent' { + $file = Initialize-LockProject -Name 'graph' -Body $script:AppBody + $lockPath = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath -PassThru + + $lockPath | Should -Be (Join-Path (Split-Path $file) 'requirements.lock.json') + $lock = Get-Content $lockPath -Raw | ConvertFrom-Json + + $lock.lockfileVersion | Should -Be 1 + $lock.dependencies.App.requested | Should -Be '[1.0,2.0)' + $lock.dependencies.App.resolved | Should -Be 'FakeResolver::App' + $lock.packages.'FakeResolver::App'.version | Should -Be '1.1.0' + # App 1.1.0 wants Lib [1.5,2.0); Util 2.0.0 wants Lib [1.0,1.6): only 1.5.0 satisfies both + $lock.packages.'FakeResolver::Lib'.version | Should -Be '1.5.0' + $lock.packages.'FakeResolver::Util'.version | Should -Be '2.0.0' + $lock.packages.'FakeResolver::Core'.version | Should -Be '2.0.0' + @($lock.packages.PSObject.Properties.Name).Count | Should -Be 4 + } + + It 'Records dependencies whose type cannot Resolve without a resolved package' { + $file = Initialize-LockProject -Name 'unresolvable' -Body $script:AppBody + $lockPath = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath -PassThru + $lock = Get-Content $lockPath -Raw | ConvertFrom-Json + + $lock.dependencies.Plain.dependencyType | Should -Be 'Noop' + $lock.dependencies.Plain.requested | Should -Be 'latest' + $lock.dependencies.Plain.PSObject.Properties.Name | Should -Not -Contain 'resolved' + } + + It 'Fails when two dependencies need incompatible versions of the same package' { + $file = Initialize-LockProject -Name 'conflict' -Body @' +@{ + App = @{ DependencyType = 'FakeResolver'; Version = '2.0.0' } + Util = @{ DependencyType = 'FakeResolver'; Version = 'latest' } +} +'@ + { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | Should -Throw -ExpectedMessage '*FakeResolver::Lib*' + Test-Path (Join-Path (Split-Path $file) 'requirements.lock.json') | Should -BeFalse + } + + It 'Fails when no version satisfies a declared constraint' { + $file = Initialize-LockProject -Name 'nomatch' -Body @' +@{ + App = @{ DependencyType = 'FakeResolver'; Version = '[5.0,)' } +} +'@ + { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | Should -Throw -ExpectedMessage '*FakeResolver::App*' + } +} + +Describe 'Get-Dependency with a lock' { + + BeforeAll { + $script:LockedFile = Initialize-LockProject -Name 'locked' -Body $script:AppBody + $null = Update-PSDependLock -Path $script:LockedFile -PSDependTypePath $script:MapPath + } + + It 'Pins the declared dependency to its locked version' { + $deps = Get-Dependency -Path $script:LockedFile + ($deps | Where-Object DependencyName -eq 'App').Version | Should -Be '1.1.0' + } + + It 'Materialises locked transitive packages as dependencies that install before their parent' { + $deps = @(Get-Dependency -Path $script:LockedFile) + $names = $deps.DependencyName + $names | Should -Contain 'Lib@1.5.0' + $names | Should -Contain 'Util@2.0.0' + $names | Should -Contain 'Core@2.0.0' + $names.Count | Should -Be 5 + + $names.IndexOf('Core@2.0.0') | Should -BeLessThan $names.IndexOf('Lib@1.5.0') + $names.IndexOf('Lib@1.5.0') | Should -BeLessThan $names.IndexOf('Util@2.0.0') + $names.IndexOf('Util@2.0.0') | Should -BeLessThan $names.IndexOf('App') + + $lib = $deps | Where-Object DependencyName -eq 'Lib@1.5.0' + $lib.DependencyType | Should -Be 'FakeResolver' + $lib.Name | Should -Be 'Lib' + $lib.Version | Should -Be '1.5.0' + $lib.Target | Should -Be ($deps | Where-Object DependencyName -eq 'App').Target + } + + It 'Leaves dependencies of a type that cannot Resolve untouched' { + $deps = Get-Dependency -Path $script:LockedFile + ($deps | Where-Object DependencyName -eq 'Plain').Version | Should -Be 'latest' + } + + It 'Returns the declared versions with -IgnoreLock' { + $deps = @(Get-Dependency -Path $script:LockedFile -IgnoreLock) + $deps.Count | Should -Be 2 + ($deps | Where-Object DependencyName -eq 'App').Version | Should -Be '[1.0,2.0)' + } + + It 'Fails with an actionable error when the dependency file no longer matches the lock' { + $file = Initialize-LockProject -Name 'stale' -Body $script:AppBody + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + Set-Content -Path $file -Value ($script:AppBody -replace "'\[1.0,2.0\)'", "'[1.0,3.0)'") + + { Get-Dependency -Path $file } | Should -Throw -ExpectedMessage '*out of date*Update-PSDependLock*' + { Get-Dependency -Path $file -IgnoreLock } | Should -Not -Throw + } + + It 'Fails when the dependency file gained a dependency the lock does not know' { + $file = Initialize-LockProject -Name 'added' -Body $script:AppBody + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + Set-Content -Path $file -Value ($script:AppBody -replace 'Plain = @\{', "Extra = @{ DependencyType = 'Noop' }`n Plain = @{") + + { Get-Dependency -Path $file } | Should -Throw -ExpectedMessage '*`[Extra`] is not in the lock*' + } +} + +Describe 'Invoke-PSDepend with a lock' { + + It 'Installs locked versions, children first' { + $file = Initialize-LockProject -Name 'install' -Body $script:AppBody + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + + Invoke-PSDepend -Path $file -PSDependTypePath $script:MapPath -Force -WarningAction SilentlyContinue + + $log = Get-Content (Join-Path (Split-Path $file) 'target/installed.log') + $log | Should -Be @('Core@2.0.0', 'Lib@1.5.0', 'Util@2.0.0', 'App@1.1.0') + } + + It 'Passes the declared range through with -IgnoreLock' { + $file = Initialize-LockProject -Name 'installignore' -Body $script:AppBody + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + + Invoke-PSDepend -Path $file -PSDependTypePath $script:MapPath -Force -IgnoreLock -WarningAction SilentlyContinue + + $log = Get-Content (Join-Path (Split-Path $file) 'target/installed.log') + $log | Should -Be @('App@[1.0,2.0)') + } +} diff --git a/Tests/PSGalleryModule.Type.Tests.ps1 b/Tests/PSGalleryModule.Type.Tests.ps1 index b99ea5f..4152342 100644 --- a/Tests/PSGalleryModule.Type.Tests.ps1 +++ b/Tests/PSGalleryModule.Type.Tests.ps1 @@ -260,4 +260,88 @@ Describe 'PSGalleryModule script' { Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 } } + + Context 'PSDependAction = Resolve' { + BeforeAll { + InModuleScope PSDepend { + Mock Find-Module { + @( + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'1.9.0'; Dependencies = @() } + [PSCustomObject]@{ + Name = 'TestModule' + Version = [version]'2.5.0' + Dependencies = @( + [ordered]@{ Name = 'psake'; MinimumVersion = '4.9.0'; CanonicalId = 'nuget:psake/4.9.0' } + [ordered]@{ Name = 'BuildHelpers'; RequiredVersion = '2.0.1'; CanonicalId = 'nuget:BuildHelpers/[2.0.1]' } + ) + } + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'3.0.0'; Dependencies = @() } + ) + } -ParameterFilter { $AllVersions } + } + } + + It 'Resolves a range to the highest in-range version and maps dependency metadata to NuGet ranges' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[2.0.0,3.0.0)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + @($result).Count | Should -Be 1 + $result.PSObject.TypeNames | Should -Contain 'PSDepend.ResolvedDependency' + $result.Name | Should -Be 'TestModule' + $result.Version | Should -Be '2.5.0' + $result.Dependencies.Count | Should -Be 2 + $result.Dependencies['psake'] | Should -Be '[4.9.0,)' + $result.Dependencies['BuildHelpers'] | Should -Be '2.0.1' + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Save-Module -ModuleName PSDepend -Times 0 + } + + It 'Resolves latest to the highest available version with an empty dependency map' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version 'latest' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '3.0.0' + $result.Dependencies.Count | Should -Be 0 + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + } + + It 'Maps Min+Max and Max-only dependency metadata to closed and open-lower ranges' { + InModuleScope PSDepend { + Mock Find-Module { + @([PSCustomObject]@{ + Name = 'TestModule' + Version = [version]'1.0.0' + Dependencies = @( + [ordered]@{ Name = 'Pester'; MinimumVersion = '5.0.0'; MaximumVersion = '5.9.9' } + [ordered]@{ Name = 'PSScriptAnalyzer'; MaximumVersion = '1.20.0' } + [ordered]@{ Name = 'Plaster' } + ) + }) + } -ParameterFilter { $AllVersions } + } + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '1.0.0' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '1.0.0' + $result.Dependencies['Pester'] | Should -Be '[5.0.0,5.9.9]' + $result.Dependencies['PSScriptAnalyzer'] | Should -Be '(,1.20.0]' + $result.Dependencies['Plaster'] | Should -Be 'latest' + } + + It 'Writes an error and emits nothing when no available version satisfies the range' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -Version '[4.0.0,5.0.0)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable resolveErr + $resolveErr + } + @($result).Count | Should -Be 1 + $result[0] | Should -BeOfType [System.Management.Automation.ErrorRecord] + $result[0].ToString() | Should -Match 'No version of \[TestModule\]' + Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Save-Module -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index 3e4edd8..1298497 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -136,4 +136,56 @@ Describe 'PSGalleryNuget script' { Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 } } + + Context 'PSDependAction = Resolve' { + BeforeAll { + InModuleScope PSDepend { + Mock Get-Command { $null } -ParameterFilter { $Name -eq 'Nuget' } + Mock BootStrap-Nuget { } + Mock Find-NugetPackage { + @( + [PSCustomObject]@{ Version = '1.9.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = '' } } + [PSCustomObject]@{ Version = '2.5.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'PSDeploy:0.2.5:|BuildHelpers:[2.0.0, ):' } } + [PSCustomObject]@{ Version = '3.0.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'BuildHelpers::' } } + [PSCustomObject]@{ Version = '3.1.0-beta1'; Properties = @{ IsPrerelease = 'true'; Dependencies = '' } } + ) + } + } + } + + It 'Resolves a range to the highest in-range version without a Target or nuget.exe' { + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Version '[2.0.0,3.0.0)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Name | Should -Be 'PSDeploy' + $result.Version | Should -Be '2.5.0' + $result.Dependencies['PSDeploy'] | Should -Be '[0.2.5,)' + $result.Dependencies['BuildHelpers'] | Should -Be '[2.0.0,)' + $result.Dependencies.Count | Should -Be 2 + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName BootStrap-Nuget -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Import-PSDependModule -ModuleName PSDepend -Times 0 + } + + It 'Resolves latest to the highest stable version, skipping prerelease' { + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '3.0.0' + $result.Dependencies['BuildHelpers'] | Should -Be 'latest' + } + + It 'Errors with no output when nothing satisfies the range' { + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Version '[5.0.0,)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable e + $e.Count | Should -Be 1 + $e[0] | Should -Match 'No version of \[PSDeploy\]' + } + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/PSResourceGet.Type.Tests.ps1 b/Tests/PSResourceGet.Type.Tests.ps1 index 904f149..9f15cbd 100644 --- a/Tests/PSResourceGet.Type.Tests.ps1 +++ b/Tests/PSResourceGet.Type.Tests.ps1 @@ -32,7 +32,7 @@ Describe 'PSResourceGet script' { } function Find-PSResource { [CmdletBinding()] param( - [string]$Name, [string]$Repository, + [string]$Name, [string]$Version, [string]$Repository, [PSCredential]$Credential, [switch]$Prerelease ) } @@ -315,4 +315,93 @@ Describe 'PSResourceGet script' { Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 0 } } + + Context 'PSDependAction = Resolve' { + BeforeAll { + # Build child dependencies from the real PSResourceGet types when the module is installed + # so the VersionRange.ToString() normalisation is exercised for real; otherwise fall back + # to objects with the same property names whose ToString() yields the normalised form. + Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction SilentlyContinue + function script:New-ResolveDependency { + param([string]$Name, [string]$Range) + $depType = 'Microsoft.PowerShell.PSResourceGet.UtilClasses.Dependency' -as [type] + if ($depType) { + if (-not $Range) { + return $depType::new($Name, $null) + } + $rangeType = $depType.GetProperty('VersionRange').PropertyType + $parsed = $rangeType.GetMethod('Parse', [type[]]@([string])).Invoke($null, @($Range)) + return $depType::new($Name, $parsed) + } + $normalized = @{ '4.9.0' = '[4.9.0, )'; '(, )' = '(, )' }[$Range] + $range = if ($Range) { [PSCustomObject]@{ Normalized = $normalized } | Add-Member ScriptMethod ToString { $this.Normalized } -Force -PassThru } else { $null } + [PSCustomObject]@{ Name = $Name; VersionRange = $range } + } + + function script:New-ResolveCatalogue { + @( + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'1.5.0'; Prerelease = ''; Dependencies = @() } + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'2.7.0'; Prerelease = ''; Dependencies = @( + (New-ResolveDependency -Name 'psake' -Range '4.9.0'), + (New-ResolveDependency -Name 'PSDeploy' -Range '(, )'), + (New-ResolveDependency -Name 'BuildHelpers' -Range $null) + ) + } + [PSCustomObject]@{ Name = 'TestModule'; Version = [version]'3.1.0'; Prerelease = ''; Dependencies = @() } + ) + } + + # The mock body runs in the caller's scope (the DependencyScript file), so hand the + # catalogue over via a module-scope variable found by dynamic scoping. + $catalogue = New-ResolveCatalogue + InModuleScope PSDepend -Parameters @{ Catalogue = $catalogue } { + $script:ResolveTestCatalogue = $Catalogue + Mock Find-PSResource { $ResolveTestCatalogue } -ParameterFilter { $Version -eq '*' } + } + } + + It 'Selects the highest version inside a range, skipping a higher one outside it' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '[2.0.0,3.0.0)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.PSTypeNames | Should -Contain 'PSDepend.ResolvedDependency' + $result.Name | Should -Be 'TestModule' + $result.Version | Should -Be '2.7.0' + } + + It 'Selects the highest available version for latest' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version 'latest' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '3.1.0' + $result.Dependencies.Count | Should -Be 0 + } + + It 'Converts dependency metadata to a NuGet-range map (bare nuspec version => lower bound; unbounded/null => latest)' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '2.7.0' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + $result.Version | Should -Be '2.7.0' + $result.Dependencies | Should -BeOfType [hashtable] + $result.Dependencies.Count | Should -Be 3 + $result.Dependencies['psake'] | Should -BeExactly '[4.9.0,)' + $result.Dependencies['PSDeploy'] | Should -BeExactly 'latest' + $result.Dependencies['BuildHelpers'] | Should -BeExactly 'latest' + } + + It 'Writes an error and emits nothing when no version satisfies the request, without installing' { + $dep = New-PSDependFixture -DependencyName 'TestModule' -DependencyType 'PSResourceGet' -Version '[4.0.0,)' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable resolveErr + $resolveErr | Should -HaveCount 1 + $resolveErr[0].ToString() | Should -Match 'No version of \[TestModule\] at \[PSGallery\] satisfies \[\[4\.0\.0,\)\]' + } + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Install-PSResource -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Save-PSResource -ModuleName PSDepend -Times 0 + } + } } diff --git a/Tests/Shared/FakeResolver.ps1 b/Tests/Shared/FakeResolver.ps1 new file mode 100644 index 0000000..3246d34 --- /dev/null +++ b/Tests/Shared/FakeResolver.ps1 @@ -0,0 +1,84 @@ +<# + .SYNOPSIS + Test double for a DependencyScript that supports the Resolve action. + + .DESCRIPTION + Serves a static package graph so lock tests can exercise + Update-PSDependLock, Get-Dependency and Invoke-PSDepend without a network. + + Install appends "@" to /installed.log so tests can + observe what was installed and in which order. Test always returns $false. + + .PARAMETER Dependency + Dependency to process + + .PARAMETER PSDependAction + Test, Install, or Resolve. +#> +[CmdletBinding()] +param( + [PSTypeName('PSDepend.Dependency')] + [PSObject[]]$Dependency, + + [ValidateSet('Test', 'Install', 'Resolve')] + [string[]]$PSDependAction = @('Install') +) + +# name -> version -> dependencies (NuGet ranges) +$Graph = @{ + App = [ordered]@{ + '1.0.0' = @{ Lib = '[1.0,2.0)'; Util = 'latest' } + '1.1.0' = @{ Lib = '[1.5,2.0)'; Util = '[2.0,)' } + '2.0.0' = @{ Lib = '[2.0,)' } + } + Lib = [ordered]@{ + '1.0.0' = @{} + '1.5.0' = @{ Core = '[1.0,)' } + '1.9.0' = @{ Core = '[1.0,)' } + '2.0.0' = @{ Core = '[2.0,)' } + } + Util = [ordered]@{ + '1.0.0' = @{ Lib = '[1.0,1.6)' } + '2.0.0' = @{ Lib = '[1.0,1.6)' } + } + Core = [ordered]@{ + '1.0.0' = @{} + '2.0.0' = @{} + } +} + +$Name = if ($Dependency.Name) { $Dependency.Name } else { $Dependency.DependencyName } +$Version = if ($Dependency.Version) { $Dependency.Version } else { 'latest' } + +if ($PSDependAction -contains 'Resolve') { + if (-not $Graph.ContainsKey($Name)) { + Write-Error "No package [$Name] in the fake feed" + return + } + $candidates = @($Graph[$Name].Keys) + $resolved = if ($Version -eq 'latest') { + $candidates[-1] + } else { + Resolve-VersionInRange -Candidate $candidates -Required $Version + } + if (-not $resolved) { + Write-Error "No version of [$Name] at [fake] satisfies [$Version]" + return + } + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $Name + Version = $resolved + Dependencies = $Graph[$Name][$resolved] + } + return +} + +if ($PSDependAction -contains 'Test') { + return $false +} + +if ($PSDependAction -contains 'Install') { + $null = New-Item -ItemType Directory -Path $Dependency.Target -Force + Add-Content -Path (Join-Path $Dependency.Target 'installed.log') -Value "$Name@$Version" +} diff --git a/docs/en-US/Get-Dependency.md b/docs/en-US/Get-Dependency.md index 4354e7f..d7286a6 100644 --- a/docs/en-US/Get-Dependency.md +++ b/docs/en-US/Get-Dependency.md @@ -16,7 +16,7 @@ Read a dependency psd1 file. ### File (Default) ``` -Get-Dependency [-Path ] [-Tags ] [-Recurse] [-Credentials ] +Get-Dependency [-Path ] [-Tags ] [-Recurse] [-Credentials ] [-IgnoreLock] [-ProgressAction ] [] ``` @@ -131,6 +131,25 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -IgnoreLock + +Skip any `.lock.json` next to a dependency file and return the dependencies exactly as declared. +By default an existing lock (see `Update-PSDependLock`) pins each locked dependency's Version and adds the +locked transitive packages as additional `Name@Version` dependencies; a lock that no longer matches its +dependency file is an error. + +```yaml +Type: SwitchParameter +Parameter Sets: File +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProgressAction {{ Fill ProgressAction Description }} diff --git a/docs/en-US/Invoke-PSDepend.md b/docs/en-US/Invoke-PSDepend.md index a2eff48..415e0bd 100644 --- a/docs/en-US/Invoke-PSDepend.md +++ b/docs/en-US/Invoke-PSDepend.md @@ -19,7 +19,7 @@ Install, import, or test dependencies defined in a PSDepend file. ``` Invoke-PSDepend [[-Path] ] [-PSDependTypePath ] [-Tags ] [-Recurse ] - [-Import] [-Install] [-Force] [-Target ] [-Credentials ] + [-Import] [-Install] [-Force] [-Target ] [-Credentials ] [-IgnoreLock] [-ProgressAction ] [-WhatIf] [-Confirm] [] ``` @@ -27,8 +27,8 @@ Invoke-PSDepend [[-Path] ] [-PSDependTypePath ] [-Tags ] [-PSDependTypePath ] [-Tags ] [-Recurse ] - [-Test] [-Quiet] [-Force] [-Target ] [-ProgressAction ] [-WhatIf] [-Confirm] - [] + [-Test] [-Quiet] [-Force] [-Target ] [-IgnoreLock] [-ProgressAction ] [-WhatIf] + [-Confirm] [] ``` ### test-hashtable @@ -314,6 +314,24 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -IgnoreLock + +Skip any `.lock.json` next to a dependency file and resolve versions as declared. By default a lock +written by `Update-PSDependLock` pins each dependency to its locked version and installs locked transitive +packages first; a lock that no longer matches its dependency file is an error. + +```yaml +Type: SwitchParameter +Parameter Sets: installimport-file, test-file +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -ProgressAction {{ Fill ProgressAction Description }} diff --git a/docs/en-US/Update-PSDependLock.md b/docs/en-US/Update-PSDependLock.md new file mode 100644 index 0000000..22abaf4 --- /dev/null +++ b/docs/en-US/Update-PSDependLock.md @@ -0,0 +1,210 @@ +--- +external help file: PSDepend-help.xml +Module Name: PSDepend +online version: https://github.com/PowerShellOrg/PSDepend +schema: 2.0.0 +--- + +# Update-PSDependLock + +## SYNOPSIS + +Resolve a dependency file's full dependency graph and write a lock file. + +## SYNTAX + +``` +Update-PSDependLock [[-Path] ] [-Recurse ] [-PSDependTypePath ] + [-Credentials ] [-PassThru] [-WhatIf] [-Confirm] [-ProgressAction ] + [] +``` + +## DESCRIPTION + +Works like npm's package-lock.json. Every dependency whose type supports the `Resolve` action +(`PSGalleryModule`, `PSResourceGet`, `PSGalleryNuget`, `Nuget`, `Chocolatey`, `Npm`) is resolved to the +highest version that satisfies its Version (exact, `latest`, or a NuGet range); its own dependencies are +resolved the same way recursively, and the result is written next to the dependency file as +`.lock.json` (`requirements.psd1` -> `requirements.lock.json`). + +A package required by several dependencies is locked to one version that satisfies all of their +constraints; conflicting constraints fail the update. `Npm` pins only the declared package and leaves +its subtree to npm's own package-lock.json. + +Once a lock exists, `Invoke-PSDepend` and `Get-Dependency` use it automatically: each dependency +installs at its locked version and locked transitive packages install first. If the dependency file +changes, the lock is reported as out of date until you run `Update-PSDependLock` again (or pass +`-IgnoreLock`). Dependency types without a `Resolve` action are recorded in the lock so drift is +detected, but install exactly as declared. + +## EXAMPLES + +### Example 1 + +```powershell +Update-PSDependLock -Path .\requirements.psd1 +``` + +Resolves every dependency (and their dependencies) in requirements.psd1 and writes requirements.lock.json. + +### Example 2 + +```powershell +Update-PSDependLock -Path C:\Project -Recurse $false -PassThru +``` + +Writes a lock for each *.depend.psd1 and requirements.psd1 directly under C:\Project and returns their paths. + +## PARAMETERS + +### -Path + +Path to a specific depend.psd1 file, or to a folder that is searched for *.depend.psd1 and +requirements.psd1 files. Defaults to the current path. + +```yaml +Type: String[] +Parameter Sets: (All) +Aliases: + +Required: False +Position: 0 +Default value: . +Accept pipeline input: True (ByValue, ByPropertyName) +Accept wildcard characters: False +``` + +### -Recurse + +If Path is a folder, whether to search it recursively. Defaults to $true. + +```yaml +Type: Boolean +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: True +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PSDependTypePath + +Path to a PSDependMap.psd1 file. Defaults to the one in the PSDepend module root. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Credentials + +Hashtable of PSCredentials keyed by the `Credential` name used in the dependency file, for +dependencies served from private feeds. + +```yaml +Type: Hashtable +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PassThru + +Return the path of each lock file that was written. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -WhatIf + +Shows what would happen if the cmdlet runs. The cmdlet is not run. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: wi + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: cf + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction + +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### System.String[] + +## OUTPUTS + +### System.String + +The lock file path, when -PassThru is specified. + +## NOTES + +## RELATED LINKS + +[Invoke-PSDepend](Invoke-PSDepend.md) + +[Get-Dependency](Get-Dependency.md) From 4919e50ff4695a3fda824b9cbd5a1f8f96c5879d Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 30 Sep 2026 21:24:11 +0000 Subject: [PATCH 2/6] fix: harden lock resolution and validation --- CHANGELOG.md | 22 ++- CONTEXT.md | 8 +- PSDepend/PSDependScripts/Chocolatey.ps1 | 9 +- PSDepend/PSDependScripts/Npm.ps1 | 12 +- PSDepend/PSDependScripts/Nuget.ps1 | 11 +- PSDepend/PSDependScripts/PSGalleryModule.ps1 | 57 +++++--- PSDepend/PSDependScripts/PSGalleryNuget.ps1 | 11 +- PSDepend/PSDependScripts/PSResourceGet.ps1 | 10 +- PSDepend/Private/Compare-Version.ps1 | 14 +- .../ConvertFrom-NugetDependencyString.ps1 | 1 + PSDepend/Private/Export-PSDependLock.ps1 | 2 +- PSDepend/Private/Find-NodeModule.ps1 | 6 +- PSDepend/Private/Find-NugetPackage.ps1 | 9 +- .../Private/Get-PSDependRequestedVersion.ps1 | 14 ++ .../Private/Get-PSDependResolutionContext.ps1 | 52 +++++++ PSDepend/Private/Import-PSDependLock.ps1 | 62 ++++++++ PSDepend/Private/Install-NodeModule.ps1 | 2 +- PSDepend/Private/Merge-PSDependLock.ps1 | 117 +++++++++++---- PSDepend/Private/Resolve-PSDependLock.ps1 | 38 +++-- .../Private/Test-PSDependExactVersion.ps1 | 25 ++++ PSDepend/Private/Test-VersionEquality.ps1 | 8 +- PSDepend/Public/Get-Dependency.ps1 | 19 ++- PSDepend/Public/Invoke-PSDepend.ps1 | 13 +- PSDepend/Public/Update-PSDependLock.ps1 | 36 ++--- PSDepend/en-US/about_PSDepend.help.txt | 40 +++--- README.md | 14 +- Tests/Chocolatey.Type.Tests.ps1 | 28 +++- Tests/Compare-Version.Tests.ps1 | 2 +- Tests/Npm.Type.Tests.ps1 | 25 +++- Tests/Nuget.Type.Tests.ps1 | 1 + Tests/PSDependLock.Tests.ps1 | 135 +++++++++++++++++- Tests/PSGalleryModule.Type.Tests.ps1 | 1 + Tests/PSGalleryNuget.Type.Tests.ps1 | 1 + Tests/PSResourceGet.Type.Tests.ps1 | 10 +- Tests/Test-VersionEquality.Tests.ps1 | 7 +- adr/0002-lock-resolution-model.md | 49 +++++++ cspell.json | 8 +- docs/en-US/Get-Dependency.md | 8 +- docs/en-US/Invoke-DependencyScript.md | 8 +- docs/en-US/Invoke-PSDepend.md | 6 +- docs/en-US/Update-PSDependLock.md | 31 ++-- 41 files changed, 713 insertions(+), 219 deletions(-) create mode 100644 PSDepend/Private/Get-PSDependRequestedVersion.ps1 create mode 100644 PSDepend/Private/Get-PSDependResolutionContext.ps1 create mode 100644 PSDepend/Private/Test-PSDependExactVersion.ps1 create mode 100644 adr/0002-lock-resolution-model.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b0573..62628cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,19 +11,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- npm-style lock files. `Update-PSDependLock` resolves every dependency and - their transitive dependencies to exact versions and writes - `.lock.json` next to the dependency file (`requirements.psd1` -> - `requirements.lock.json`). One version is locked per package across the - whole file; conflicting constraints fail the update. `Invoke-PSDepend` and - `Get-Dependency` honor an existing lock automatically, install locked - transitive packages first, and error when the dependency file no longer - matches the lock; `-IgnoreLock` opts out. +- npm-style lock files. `Update-PSDependLock` resolves supported Dependencies + and their transitive dependencies to exact versions in `.lock.json`. + `Invoke-PSDepend` and `Get-Dependency` honor locks automatically, install + transitive packages first, preserve separate root installation contexts, + reject malformed or unsafe lock data, and detect changes to Dependencies, + versions, resolution sources, and DependencyScript parameters. `-IgnoreLock` + opts out. Resolution is greedy and does not backtrack to older parent versions. - New `Resolve` PSDependAction for `PSGalleryModule`, `PSResourceGet`, - `PSGalleryNuget`, `Nuget`, `Chocolatey` and `Npm`: query the source for the - highest version satisfying `Version` and report its dependencies as NuGet - ranges, without installing. `Npm` pins only the declared package and leaves - its subtree to npm's `package-lock.json`. + `PSGalleryNuget`, `Nuget`, `Chocolatey` and `Npm`: query the source and return + an exact version without installing. `Npm` accepts npm semver ranges and pins + only the declared package; its subtree remains under npm's `package-lock.json`. ### Fixed diff --git a/CONTEXT.md b/CONTEXT.md index 7f457ae..8020bba 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,15 +41,15 @@ A label on a Dependency that controls inclusion when `Invoke-PSDepend` is called _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 +A constraint on which versions satisfy a Dependency, expressed in the DependencyScript's syntax inside Version. Gallery, NuGet, and Chocolatey Dependencies use NuGet range syntax (for example `[2.2.3,3.0)`); Npm uses npm semver. A bare version (`3.2.1`) means exactly that version. +_Avoid_: version spec, MinimumVersion/MaximumVersion **Lock**: -A `.lock.json` file next to a DependencyFile, written by `Update-PSDependLock`, that records one exact version per `DependencyType::Name` for every Dependency (and its transitive dependencies) whose DependencyScript supports the Resolve PSDependAction. Consumed automatically by `Get-Dependency`/`Invoke-PSDepend`; out of date when the DependencyFile no longer matches. +A `.lock.json` file next to a DependencyFile, written by `Update-PSDependLock`, that records one exact version per `DependencyType::Name` for every Dependency whose DependencyScript supports Resolve, including transitive dependencies. Root entries also fingerprint resolution Source and Parameters. Consumed automatically by `Get-Dependency`/`Invoke-PSDepend`; malformed, unsafe, or stale locks are rejected. _Avoid_: lockfile (npm's), pin file, freeze **Resolve**: -The PSDependAction that asks a DependencyScript for the highest version satisfying a Version (or VersionRange) at its source, plus that version's own dependencies as VersionRanges, without installing. Runs alone; only DependencyScripts that opt in support it. +The PSDependAction that asks a DependencyScript for the highest version satisfying Version at its source, plus direct dependencies as ranges, without installing. Runs alone; only DependencyScripts that opt in support it. The graph engine is greedy and does not backtrack to older parent versions. _Avoid_: lookup, query, find ## Relationships diff --git a/PSDepend/PSDependScripts/Chocolatey.ps1 b/PSDepend/PSDependScripts/Chocolatey.ps1 index c5ab39b..ac7aeb7 100644 --- a/PSDepend/PSDependScripts/Chocolatey.ps1 +++ b/PSDepend/PSDependScripts/Chocolatey.ps1 @@ -1,4 +1,4 @@ -# cspell:ignore lessmsi +# cspell:ignore lessmsi <# .SYNOPSIS Installs a package from a Chocolatey repository. @@ -28,7 +28,8 @@ Test: Return true or false on whether the dependency is in place Install: Install the dependency - Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. + Resolve: Query the source for the highest version satisfying Version and report + its dependencies. Requires an HTTP(S) NuGet v2 feed URL and performs no installation. .EXAMPLE @{ @@ -244,6 +245,10 @@ if ($PSDependAction -contains 'Resolve') { Write-Error "Resolve for [$Name] requires a NuGet v2 feed URL as Source; got [$Source]" return } + if ($Credential -and $Source -notmatch '^https://') { + Write-Error "Resolve for [$Name] requires an HTTPS Source when Credential is supplied; got [$Source]" + return + } $findParams = @{ Name = $Name diff --git a/PSDepend/PSDependScripts/Npm.ps1 b/PSDepend/PSDependScripts/Npm.ps1 index c8eeaa0..5657a8b 100644 --- a/PSDepend/PSDependScripts/Npm.ps1 +++ b/PSDepend/PSDependScripts/Npm.ps1 @@ -7,13 +7,14 @@ Note: We require npm in your path. - Lock behaviour (Resolve): PSDepend's lock pins only the declared package to an + Lock behavior (Resolve): PSDepend's lock pins only the declared package to an exact version. Transitive node dependencies are not resolved by PSDepend; npm's own package-lock.json governs the package's subtree. Relevant Dependency metadata: DependencyName (Key): Node Package Name - Version: Version of the node package to install; defaults to latest. + Version: Exact version or npm semver range (for example, '^1.2.0' or + '>=1 <2'); defaults to latest. NuGet range syntax is not supported. Target: Path to place the node_modules folder, and all relevant packages, in. You can specify a full path, a UNC path, or a relative path from the current directory. You can also specify the special keyword, 'Global', @@ -31,7 +32,8 @@ Test: Return true or false on whether the dependency is in place Install: Install the dependency - Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. + Resolve: Query npm for the highest version satisfying Version and report it. + NuGet range syntax is rejected. Performs no installation. .EXAMPLE @{ @@ -88,6 +90,10 @@ If (-not [string]::IsNullOrEmpty($Target) -and $Target -ne 'global') { #endregion Extract Dependency Data #region Resolve Action If ($PSDependAction -contains 'Resolve') { + if ($Version -match '[\[\]\(\),]') { + Write-Error "Npm dependency [$Name] uses NuGet range syntax [$Version]; use an npm semver range instead" + return + } $Candidates = @(Find-NodeModule -PackageName $Name -Version $Version) $Resolved = $null foreach ($Candidate in $Candidates) { diff --git a/PSDepend/PSDependScripts/Nuget.ps1 b/PSDepend/PSDependScripts/Nuget.ps1 index 82becfc..81d4350 100644 --- a/PSDepend/PSDependScripts/Nuget.ps1 +++ b/PSDepend/PSDependScripts/Nuget.ps1 @@ -21,7 +21,8 @@ Test: Return true or false on whether the dependency is in place Install: Install the dependency - Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. + Resolve: Query the source for the highest version satisfying Version and report + its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @@ -100,17 +101,17 @@ $Credential = $Dependency.Credential if ($PSDependAction -contains 'Resolve') { $packages = @(Find-NugetPackage -Name $DependencyName -PackageSourceUrl $Source -Credential $Credential) + $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) $resolvedVersion = $null if ($Version -eq 'latest') { - $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) foreach ($package in $stable) { - if (-not $resolvedVersion -or (Compare-Version $package.Version $resolvedVersion) -gt 0) { + if (-not $resolvedVersion -or (Compare-Version -ReferenceVersion $package.Version -DifferenceVersion $resolvedVersion) -gt 0) { $resolvedVersion = $package.Version } } } else { - $resolvedVersion = Resolve-VersionInRange -Candidate @($packages.Version) -Required $Version + $resolvedVersion = Resolve-VersionInRange -Candidate @($stable.Version) -Required $Version } if (-not $resolvedVersion) { Write-Error "No version of [$DependencyName] at [$Source] satisfies [$Version]" @@ -121,7 +122,7 @@ if ($PSDependAction -contains 'Resolve') { PSTypeName = 'PSDepend.ResolvedDependency' Name = $DependencyName Version = $resolvedVersion - Dependencies = ConvertFrom-NugetDependencyString -Dependencies $resolved.Properties.Dependencies + Dependencies = ConvertFrom-NugetDependencyString -Dependencies ([string]$resolved.Properties.Dependencies) } return } diff --git a/PSDepend/PSDependScripts/PSGalleryModule.ps1 b/PSDepend/PSDependScripts/PSGalleryModule.ps1 index 5d5f392..01deed8 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -45,7 +45,8 @@ Test: Return true or false on whether the dependency is in place Install: Install the dependency Import: Import the dependency - Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. + Resolve: Query the source for the highest version satisfying Version and report + its dependencies. Honors AllowPrerelease and performs no installation. .EXAMPLE @{ @@ -151,22 +152,24 @@ else { $command = 'install' } -$nugetProvider = @(Get-PackageProvider -ErrorAction SilentlyContinue) | - Where-Object { $_.Name -eq 'NuGet' } | - Select-Object -First 1 - -if (-not $nugetProvider) { - Write-Debug 'NuGet provider not found. Attempting to install NuGet provider.' - # Bootstrap NuGet provider for Windows PowerShell 5.1 and PowerShell 7+. - $installPackageProviderSplat = @{ - Name = 'NuGet' - ForceBootstrap = $true - Force = $true - Scope = 'CurrentUser' - ErrorAction = 'SilentlyContinue' - } +if ($PSDependAction -notcontains 'Resolve') { + $nugetProvider = @(Get-PackageProvider -ErrorAction SilentlyContinue) | + Where-Object { $_.Name -eq 'NuGet' } | + Select-Object -First 1 + + if (-not $nugetProvider) { + Write-Debug 'NuGet provider not found. Attempting to install NuGet provider.' + # Bootstrap NuGet provider for Windows PowerShell 5.1 and PowerShell 7+. + $installPackageProviderSplat = @{ + Name = 'NuGet' + ForceBootstrap = $true + Force = $true + Scope = 'CurrentUser' + ErrorAction = 'SilentlyContinue' + } - $null = Install-PackageProvider @installPackageProviderSplat + $null = Install-PackageProvider @installPackageProviderSplat + } } Write-Verbose -Message "Getting dependency [$name] from PowerShell repository [$Repository]" @@ -259,12 +262,22 @@ if ($PSDependAction -contains 'Resolve') { if (-not $dep -or -not $dep['Name']) { continue } $min = $dep['MinimumVersion'] $max = $dep['MaximumVersion'] - $childDependencies[$dep['Name']] = - if ($dep['RequiredVersion']) { [string]$dep['RequiredVersion'] } - elseif ($min -and $max) { "[$min,$max]" } - elseif ($min) { "[$min,)" } - elseif ($max) { "(,$max]" } - else { 'latest' } + if ($dep['RequiredVersion']) { + $range = [string]$dep['RequiredVersion'] + } + elseif ($min -and $max) { + $range = "[$min,$max]" + } + elseif ($min) { + $range = "[$min,)" + } + elseif ($max) { + $range = "(,$max]" + } + else { + $range = 'latest' + } + $childDependencies[$dep['Name']] = $range } $canonicalName = if ($selected.Name) { $selected.Name } else { $Name } diff --git a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 index 8e9ab11..5ca05d3 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -28,7 +28,8 @@ Test: Return true or false on whether the dependency is in place Install: Install the dependency Import: Import the dependency - Resolve: Query the source for the highest version satisfying Version and report its dependencies. Used by Update-PSDependLock; performs no installation. + Resolve: Query the source for the highest version satisfying Version and report + its dependencies. Used by Update-PSDependLock; performs no installation. .EXAMPLE @@ -88,17 +89,17 @@ $Credential = $Dependency.Credential if ($PSDependAction -contains 'Resolve') { $packages = @(Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential) + $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) $resolvedVersion = $null if ($Version -eq 'latest') { - $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) foreach ($package in $stable) { - if (-not $resolvedVersion -or (Compare-Version $package.Version $resolvedVersion) -gt 0) { + if (-not $resolvedVersion -or (Compare-Version -ReferenceVersion $package.Version -DifferenceVersion $resolvedVersion) -gt 0) { $resolvedVersion = $package.Version } } } else { - $resolvedVersion = Resolve-VersionInRange -Candidate @($packages.Version) -Required $Version + $resolvedVersion = Resolve-VersionInRange -Candidate @($stable.Version) -Required $Version } if (-not $resolvedVersion) { Write-Error "No version of [$Name] at [$Source] satisfies [$Version]" @@ -109,7 +110,7 @@ if ($PSDependAction -contains 'Resolve') { PSTypeName = 'PSDepend.ResolvedDependency' Name = $Name Version = $resolvedVersion - Dependencies = ConvertFrom-NugetDependencyString -Dependencies $resolved.Properties.Dependencies + Dependencies = ConvertFrom-NugetDependencyString -Dependencies ([string]$resolved.Properties.Dependencies) } return } diff --git a/PSDepend/PSDependScripts/PSResourceGet.ps1 b/PSDepend/PSDependScripts/PSResourceGet.ps1 index 76ffeb4..0fb590a 100644 --- a/PSDepend/PSDependScripts/PSResourceGet.ps1 +++ b/PSDepend/PSDependScripts/PSResourceGet.ps1 @@ -266,18 +266,18 @@ foreach ($thisParameter in $params.Keys) { $params = $tempParams.Clone() if ($PSDependAction -contains 'Resolve') { - $FindModuleParams = @{ Name = $Name; Version = '*' } + $findModuleParams = @{ Name = $Name; Version = '*' } if ($Repository) { - $FindModuleParams.Add('Repository', $Repository) + $findModuleParams.Add('Repository', $Repository) } if ($Credential) { - $FindModuleParams.Add('Credential', $Credential) + $findModuleParams.Add('Credential', $Credential) } if ($Prerelease) { - $FindModuleParams.Add('Prerelease', $true) + $findModuleParams.Add('Prerelease', $true) } - $available = @(Find-PSResource @FindModuleParams -ErrorAction SilentlyContinue) + $available = @(Find-PSResource @findModuleParams -ErrorAction SilentlyContinue) $candidates = @{} foreach ($found in $available) { $candidateVersion = $found.Version.ToString() diff --git a/PSDepend/Private/Compare-Version.ps1 b/PSDepend/Private/Compare-Version.ps1 index e46aa53..8e988ba 100644 --- a/PSDepend/Private/Compare-Version.ps1 +++ b/PSDepend/Private/Compare-Version.ps1 @@ -5,10 +5,10 @@ function Compare-Version { .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 + [IComparable]. SemanticVersion is tried first so prerelease ordering is + honored (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 + components are normalized 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 @@ -48,26 +48,26 @@ function Compare-Version { } # System.Version fallback handles four-part versions SemVer rejects. - # Normalise absent components (-1) to 0 so 1.2.3 equals 1.2.3.0. + # Normalize 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( + $refNormalized = [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( + $diffNormalized = [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) + return $refNormalized.CompareTo($diffNormalized) } # Neither type parses both: ordinal string comparison, clamped to -1/0/1. diff --git a/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 b/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 index aa3baec..1c9bb5c 100644 --- a/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 +++ b/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 @@ -1,3 +1,4 @@ +# cspell:ignore Newtonsoft function ConvertFrom-NugetDependencyString { <# .SYNOPSIS diff --git a/PSDepend/Private/Export-PSDependLock.ps1 b/PSDepend/Private/Export-PSDependLock.ps1 index bc76069..952fe4a 100644 --- a/PSDepend/Private/Export-PSDependLock.ps1 +++ b/PSDepend/Private/Export-PSDependLock.ps1 @@ -4,7 +4,7 @@ function Export-PSDependLock { Write a lock object to disk as JSON. .DESCRIPTION - Serialises the ordered lock object produced by Resolve-PSDependLock. Keys are + Serializes the ordered lock object produced by Resolve-PSDependLock. Keys are already sorted by the producer so the file diffs cleanly under source control. The file is written as UTF-8 without a BOM. diff --git a/PSDepend/Private/Find-NodeModule.ps1 b/PSDepend/Private/Find-NodeModule.ps1 index e7dfa2e..71c74b2 100644 --- a/PSDepend/Private/Find-NodeModule.ps1 +++ b/PSDepend/Private/Find-NodeModule.ps1 @@ -6,7 +6,7 @@ function Find-NodeModule { .DESCRIPTION Runs `npm view [@] version --json` and returns the matching version strings. npm prints a single JSON string when one version matches and a - JSON array when several do; both are normalised to [string[]]. + JSON array when several do; both are normalized to [string[]]. Writes an error and returns nothing when npm is not on PATH. @@ -34,9 +34,9 @@ function Find-NodeModule { } if ([string]::IsNullOrEmpty($Version) -or $Version -eq 'latest') { - $json = npm view $PackageName version --json + $json = npm view --json -- $PackageName version } else { - $json = npm view "$PackageName@$Version" version --json + $json = npm view --json -- "$PackageName@$Version" version } if ([string]::IsNullOrWhiteSpace(($json -join ''))) { diff --git a/PSDepend/Private/Find-NugetPackage.ps1 b/PSDepend/Private/Find-NugetPackage.ps1 index 1e26a61..19b29ef 100644 --- a/PSDepend/Private/Find-NugetPackage.ps1 +++ b/PSDepend/Private/Find-NugetPackage.ps1 @@ -18,18 +18,21 @@ function Find-NugetPackage { [PSCredential]$Credential = $null ) + + $escapedName = ([string]$Name).Replace("'", "''") + $escapedVersion = ([string]$Version).Replace("'", "''") #Ugly way to do this. Prefer islatest, otherwise look for version, otherwise grab all matching modules if ($IsLatest) { Write-Verbose "Searching for latest [$name] module" - $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name' and IsLatestVersion" + $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$escapedName' and IsLatestVersion" } elseif ($PSBoundParameters.ContainsKey('Version')) { Write-Verbose "Searching for version [$version] of [$name]" - $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name' and Version eq '$Version'" + $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$escapedName' and Version eq '$escapedVersion'" } else { Write-Verbose "Searching for all versions of [$name] module" - $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$name'" + $URI = "${PackageSourceUrl}Packages?`$filter=Id eq '$escapedName'" } $headers = @{} diff --git a/PSDepend/Private/Get-PSDependRequestedVersion.ps1 b/PSDepend/Private/Get-PSDependRequestedVersion.ps1 new file mode 100644 index 0000000..5c2ebb2 --- /dev/null +++ b/PSDepend/Private/Get-PSDependRequestedVersion.ps1 @@ -0,0 +1,14 @@ +function Get-PSDependRequestedVersion { + <# + .SYNOPSIS + Normalize an omitted Dependency Version to latest. + + .PARAMETER Version + Declared Dependency Version. + #> + [CmdletBinding()] + [OutputType([string])] + param($Version) + + if ([string]::IsNullOrEmpty($Version)) { 'latest' } else { [string]$Version } +} diff --git a/PSDepend/Private/Get-PSDependResolutionContext.ps1 b/PSDepend/Private/Get-PSDependResolutionContext.ps1 new file mode 100644 index 0000000..c6af15a --- /dev/null +++ b/PSDepend/Private/Get-PSDependResolutionContext.ps1 @@ -0,0 +1,52 @@ +function Get-PSDependResolutionContext { + <# + .SYNOPSIS + Fingerprint the dependency metadata that can affect version resolution. + + .DESCRIPTION + Produces a stable SHA-256 fingerprint from Source and Parameters without + writing either value to the lock file. Dictionary keys are sorted so + equivalent hashtables produce the same fingerprint regardless of insertion + order. Target is deliberately excluded because it affects installation, + not version resolution. + + .PARAMETER Dependency + The PSDepend Dependency to fingerprint. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [PSObject]$Dependency + ) + + function ConvertTo-CanonicalValue { + param($Value) + + if ($Value -is [System.Collections.IDictionary]) { + $result = [ordered]@{} + foreach ($key in @($Value.Keys | Sort-Object)) { + $result[[string]$key] = ConvertTo-CanonicalValue -Value $Value[$key] + } + return $result + } + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + return @($Value | ForEach-Object { ConvertTo-CanonicalValue -Value $_ }) + } + $Value + } + + $context = [ordered]@{ + source = $Dependency.Source + parameters = ConvertTo-CanonicalValue -Value $Dependency.Parameters + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $context -Compress -Depth 20)) + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash($bytes) + } + finally { + $sha256.Dispose() + } + -join ($hash | ForEach-Object { $_.ToString('x2') }) +} diff --git a/PSDepend/Private/Import-PSDependLock.ps1 b/PSDepend/Private/Import-PSDependLock.ps1 index 9ec06ca..84051ce 100644 --- a/PSDepend/Private/Import-PSDependLock.ps1 +++ b/PSDepend/Private/Import-PSDependLock.ps1 @@ -33,6 +33,15 @@ function Import-PSDependLock { $InputObject } + + function Assert-Table { + param($Value, [string]$Description) + + if ($Value -isnot [System.Collections.IDictionary]) { + throw "Lock file [$Path] is corrupt: $Description must be a JSON object" + } + } + $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop try { $parsed = ConvertFrom-Json -InputObject $raw -ErrorAction Stop @@ -41,6 +50,9 @@ function Import-PSDependLock { } $lock = ConvertTo-OrderedHashtable $parsed + if ($lock -isnot [System.Collections.IDictionary]) { + throw "Lock file [$Path] is not a PSDepend lock file (the top level must be a JSON object)" + } if (-not $lock.Contains('lockfileVersion') -or -not $lock.Contains('dependencies') -or -not $lock.Contains('packages')) { throw "Lock file [$Path] is not a PSDepend lock file (missing lockfileVersion, dependencies or packages)" } @@ -48,5 +60,55 @@ function Import-PSDependLock { throw "Lock file [$Path] uses lockfileVersion [$($lock.lockfileVersion)]; this version of PSDepend supports lockfileVersion 1. Regenerate it with Update-PSDependLock" } + Assert-Table -Value $lock.dependencies -Description 'dependencies' + Assert-Table -Value $lock.packages -Description 'packages' + + foreach ($dependencyName in $lock.dependencies.Keys) { + $entry = $lock.dependencies[$dependencyName] + Assert-Table -Value $entry -Description "dependency [$dependencyName]" + foreach ($requiredProperty in 'dependencyType', 'name', 'requested') { + if (-not $entry.Contains($requiredProperty) -or [string]::IsNullOrWhiteSpace([string]$entry[$requiredProperty])) { + throw "Lock file [$Path] is corrupt: dependency [$dependencyName] has no [$requiredProperty]" + } + } + if ($entry.Contains('contextHash') -and [string]$entry.contextHash -notmatch '^[0-9a-f]{64}$') { + throw "Lock file [$Path] is corrupt: dependency [$dependencyName] has an invalid resolution context hash" + } + if ($entry.Contains('resolved')) { + $expectedKey = "$($entry.dependencyType)::$($entry.name)" + if ($entry.resolved -cne $expectedKey) { + throw "Lock file [$Path] is corrupt: dependency [$dependencyName] has resolved key [$($entry.resolved)]; expected [$expectedKey]" + } + if (-not $lock.packages.Contains($entry.resolved)) { + throw "Lock file [$Path] is corrupt: dependency [$dependencyName] references package [$($entry.resolved)], which is not locked" + } + } + } + + foreach ($packageKey in $lock.packages.Keys) { + if ([string]$packageKey -notmatch '^([^:]+)::([A-Za-z0-9@][A-Za-z0-9._/@-]*)$') { + throw "Lock file [$Path] is corrupt: package key [$packageKey] is not a valid DependencyType::Name" + } + $dependencyType = $Matches[1] + $package = $lock.packages[$packageKey] + Assert-Table -Value $package -Description "package [$packageKey]" + if (-not $package.Contains('version') -or -not (Test-PSDependExactVersion -Version ([string]$package.version))) { + throw "Lock file [$Path] is corrupt: package [$packageKey] must have an exact version" + } + if (-not $package.Contains('dependencies')) { + throw "Lock file [$Path] is corrupt: package [$packageKey] has no dependencies object" + } + Assert-Table -Value $package.dependencies -Description "dependencies of package [$packageKey]" + foreach ($childName in $package.dependencies.Keys) { + if ([string]$childName -notmatch '^[A-Za-z0-9@][A-Za-z0-9._/@-]*$') { + throw "Lock file [$Path] is corrupt: package [$packageKey] has invalid dependency name [$childName]" + } + $childKey = "${dependencyType}::$childName" + if (-not $lock.packages.Contains($childKey)) { + throw "Lock file [$Path] is corrupt: package [$packageKey] references [$childKey], which is not locked" + } + } + } + $lock } diff --git a/PSDepend/Private/Install-NodeModule.ps1 b/PSDepend/Private/Install-NodeModule.ps1 index 9c6bf2f..2e07781 100644 --- a/PSDepend/Private/Install-NodeModule.ps1 +++ b/PSDepend/Private/Install-NodeModule.ps1 @@ -5,5 +5,5 @@ [switch]$Global, [string]$PackageName ) - npm install --silent $(If ($Global -eq $true) { '--global' }) $PackageName$(If(![string]::IsNullOrEmpty($Version)){"@$Version"}) + npm install --silent $(If ($Global -eq $true) { '--global' }) -- $PackageName$(If(![string]::IsNullOrEmpty($Version)){"@$Version"}) } diff --git a/PSDepend/Private/Merge-PSDependLock.ps1 b/PSDepend/Private/Merge-PSDependLock.ps1 index 4c848a6..7fe8793 100644 --- a/PSDepend/Private/Merge-PSDependLock.ps1 +++ b/PSDepend/Private/Merge-PSDependLock.ps1 @@ -11,11 +11,12 @@ function Merge-PSDependLock { so a stale lock is never silently installed. Then, for each locked Dependency, the requested Version is replaced by the - exact locked version and the locked transitive packages are materialised as - additional Dependency objects (named Name@Version) cloned from the - Dependency that pulled them in, with DependsOn edges so children install - before parents. A package required by several parents is emitted once. - Dependencies whose type could not be locked pass through untouched. + exact locked version and the locked transitive packages are materialized as + additional Dependency objects cloned from the Dependency that pulled them + in. DependsOn edges ensure children install before parents. A package is + materialized once per root so roots with different installation contexts + each receive their transitive dependencies. Dependencies whose type could + not be locked pass through untouched. .PARAMETER Dependency The Dependencies parsed from one DependencyFile. @@ -26,12 +27,22 @@ function Merge-PSDependLock { .PARAMETER LockPath Path of the lock file, used in error messages. + .PARAMETER DependencyFile + Path of the DependencyFile represented by Dependency. Required even when + the file contains no Dependencies so stale locks can still be rejected. + .EXAMPLE Merge-PSDependLock -Dependency $deps -Lock (Import-PSDependLock $lockPath) -LockPath $lockPath #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', + '', + Justification = 'Transforms in-memory Dependency objects; it does not change external state.' + )] [CmdletBinding()] [OutputType([PSObject[]])] param( + [AllowEmptyCollection()] [PSTypeName('PSDepend.Dependency')] [PSObject[]]$Dependency, @@ -39,10 +50,11 @@ function Merge-PSDependLock { [System.Collections.IDictionary]$Lock, [Parameter(Mandatory)] - [string]$LockPath - ) + [string]$LockPath, - $dependencyFile = $Dependency[0].DependencyFile + [Parameter(Mandatory)] + [string]$DependencyFile + ) # Stale check: the lock's view of the DependencyFile must match what was parsed $problems = New-Object System.Collections.ArrayList @@ -50,14 +62,21 @@ function Merge-PSDependLock { foreach ($root in $Dependency) { $seen[$root.DependencyName] = $true $name = if ($root.Name) { $root.Name } else { $root.DependencyName } - $requested = if ([string]::IsNullOrEmpty($root.Version)) { 'latest' } else { [string]$root.Version } + $requested = Get-PSDependRequestedVersion -Version $root.Version + $contextHash = Get-PSDependResolutionContext -Dependency $root if (-not $Lock.dependencies.Contains($root.DependencyName)) { $null = $problems.Add("[$($root.DependencyName)] is not in the lock") continue } $entry = $Lock.dependencies[$root.DependencyName] if ($entry.dependencyType -ne $root.DependencyType -or $entry.name -ne $name -or $entry.requested -ne $requested) { - $null = $problems.Add("[$($root.DependencyName)] changed: lock has [$($entry.dependencyType)] [$($entry.name)] [$($entry.requested)], file has [$($root.DependencyType)] [$name] [$requested]") + $null = $problems.Add( + "[$($root.DependencyName)] changed: lock has [$($entry.dependencyType)] [$($entry.name)] [$($entry.requested)], " + + "file has [$($root.DependencyType)] [$name] [$requested]" + ) + } + if ($entry.contextHash -ne $contextHash) { + $null = $problems.Add("[$($root.DependencyName)] resolution source or parameters changed") } } foreach ($lockedName in $Lock.dependencies.Keys) { @@ -66,18 +85,34 @@ function Merge-PSDependLock { } } if ($problems.Count -gt 0) { - throw "Lock file [$LockPath] is out of date with [$dependencyFile]:`n - $($problems -join "`n - ")`nRun Update-PSDependLock -Path '$dependencyFile' to refresh it, or use -IgnoreLock to resolve without it." + $message = "Lock file [$LockPath] is out of date with [$DependencyFile]:`n - $($problems -join "`n - ")" + $message += "`nRun Update-PSDependLock -Path '$DependencyFile' to refresh it, or use -IgnoreLock to resolve without it" + throw $message } - $rootByKey = @{} + + $synthesized = [ordered]@{} + $usedDependencyNames = @{} foreach ($root in $Dependency) { - $entry = $Lock.dependencies[$root.DependencyName] - if ($entry.Contains('resolved') -and -not $rootByKey.ContainsKey($entry.resolved)) { - $rootByKey[$entry.resolved] = $root - } + $usedDependencyNames[$root.DependencyName] = $true } - $synthesized = [ordered]@{} + function New-LockedDependencyName { + param([string]$Name, [string]$Version, [string]$ContextName) + + $baseName = "$Name@$Version" + $candidate = $baseName + if ($usedDependencyNames.ContainsKey($candidate)) { + $candidate = "$baseName#$ContextName" + } + $suffix = 2 + while ($usedDependencyNames.ContainsKey($candidate)) { + $candidate = "$baseName#$ContextName-$suffix" + $suffix++ + } + $usedDependencyNames[$candidate] = $true + $candidate + } function Get-LockedPackage { param([string]$Key) @@ -87,22 +122,30 @@ function Merge-PSDependLock { $Lock.packages[$Key] } - # Returns the DependencyName that represents $Key, creating a synthesized - # Dependency (and, recursively, its children) when it is not a root. + # Materialization is scoped to a root Dependency. The same locked package + # may need installing more than once when roots use different Targets, + # Sources, Parameters or Tags. function Resolve-LockedChild { - param([string]$Key, [PSObject]$Template) - if ($rootByKey.ContainsKey($Key)) { - return $rootByKey[$Key].DependencyName + param( + [string]$Key, + [PSObject]$Template, + [string]$RootKey, + [string]$ContextName + ) + + if ($Key -eq $RootKey) { + return $Template.DependencyName } - if ($synthesized.Contains($Key)) { - return $synthesized[$Key].DependencyName + $materializationKey = "$ContextName`0$Key" + if ($synthesized.Contains($materializationKey)) { + return $synthesized[$materializationKey].DependencyName } $package = Get-LockedPackage -Key $Key $name = $Key.Substring($Key.IndexOf('::') + 2) $child = [PSCustomObject]@{ PSTypeName = 'PSDepend.Dependency' DependencyFile = $Template.DependencyFile - DependencyName = "$name@$($package.version)" + DependencyName = (New-LockedDependencyName -Name $name -Version $package.version -ContextName $ContextName) DependencyType = $Template.DependencyType Name = $name Version = $package.version @@ -118,15 +161,25 @@ function Merge-PSDependLock { PSDependOptions = $Template.PSDependOptions Raw = $null } - $synthesized[$Key] = $child - $child.DependsOn = Get-LockedChildList -Package $package -DependencyType $Template.DependencyType -Template $Template + # Register before recursion so a cycle terminates at the existing node. + $synthesized[$materializationKey] = $child + $child.DependsOn = Get-LockedChildList -Package $package -DependencyType $Template.DependencyType ` + -Template $Template -RootKey $RootKey -ContextName $ContextName $child.DependencyName } function Get-LockedChildList { - param($Package, [string]$DependencyType, [PSObject]$Template) + param( + $Package, + [string]$DependencyType, + [PSObject]$Template, + [string]$RootKey, + [string]$ContextName + ) + $names = foreach ($childName in $Package.dependencies.Keys) { - Resolve-LockedChild -Key "${DependencyType}::$childName" -Template $Template + Resolve-LockedChild -Key "${DependencyType}::$childName" -Template $Template ` + -RootKey $RootKey -ContextName $ContextName } if ($names) { @($names) } else { $null } } @@ -139,9 +192,11 @@ function Merge-PSDependLock { $package = Get-LockedPackage -Key $entry.resolved Write-Verbose "Lock pins [$($root.DependencyName)] to [$($package.version)] (requested [$($entry.requested)])" $root.Version = $package.version - $children = Get-LockedChildList -Package $package -DependencyType $root.DependencyType -Template $root + $children = Get-LockedChildList -Package $package -DependencyType $root.DependencyType ` + -Template $root -RootKey $entry.resolved -ContextName $root.DependencyName if ($children) { - $root.DependsOn = @(@($root.DependsOn) + $children | Where-Object { $_ } | Select-Object -Unique) + $dependencies = @($root.DependsOn) + $children + $root.DependsOn = @($dependencies | Where-Object { $_ } | Select-Object -Unique) } } diff --git a/PSDepend/Private/Resolve-PSDependLock.ps1 b/PSDepend/Private/Resolve-PSDependLock.ps1 index 9086ea1..876b563 100644 --- a/PSDepend/Private/Resolve-PSDependLock.ps1 +++ b/PSDepend/Private/Resolve-PSDependLock.ps1 @@ -12,7 +12,7 @@ function Resolve-PSDependLock { constraints (Join-VersionRange), so the lock holds exactly one version per DependencyType::Name. When a parent is re-resolved its previous child constraints are dropped and the loop continues until no node violates a - constraint (a fixpoint), then unreachable nodes are pruned. + constraint (a fixed point), then unreachable nodes are pruned. Dependencies whose DependencyType cannot Resolve (or is unsupported on this platform) are recorded without a resolved package so a later run can still @@ -42,7 +42,8 @@ function Resolve-PSDependLock { [string]$PSDependTypePath = $(Join-Path $ModuleRoot PSDependMap.psd1) ) - $maxIterations = 10000 + $maxIterations = 1000 + $resolutionStates = @{} $types = Get-PSDependType -Path $PSDependTypePath -SkipHelp $scripts = Get-PSDependScript -Path $PSDependTypePath @@ -66,22 +67,19 @@ function Resolve-PSDependLock { $resolvable[$DependencyType] } - function Get-Requested { - param($Version) - if ([string]::IsNullOrEmpty($Version)) { 'latest' } else { [string]$Version } - } - $roots = [ordered]@{} - $nodes = @{} # key -> @{ Name; DependencyType; Version; Dependencies; Template; Constraints = @{ source -> range } } + $nodes = @{} # key -> resolved node with constraints and resolution context $queue = New-Object System.Collections.Generic.Queue[string] foreach ($root in $Dependency) { $name = if ($root.Name) { $root.Name } else { $root.DependencyName } - $requested = Get-Requested $root.Version + $requested = Get-PSDependRequestedVersion -Version $root.Version + $contextHash = Get-PSDependResolutionContext -Dependency $root $entry = [ordered]@{ dependencyType = $root.DependencyType name = $name requested = $requested + contextHash = $contextHash } if (Test-Resolvable -DependencyType $root.DependencyType) { $key = "$($root.DependencyType)::$name" @@ -93,8 +91,11 @@ function Resolve-PSDependLock { Version = $null Dependencies = @{} Template = $root + ContextHash = $contextHash Constraints = @{} } + } elseif ($nodes[$key].ContextHash -ne $contextHash) { + throw "Cannot lock [$key] from different resolution sources or parameters; declare a single source and parameter set for each DependencyType::Name" } $nodes[$key].Constraints["root:$($root.DependencyName)"] = $requested $queue.Enqueue($key) @@ -104,8 +105,8 @@ function Resolve-PSDependLock { $iterations = 0 while ($queue.Count -gt 0) { - if (++$iterations -gt $maxIterations) { - throw "Dependency resolution did not converge after [$maxIterations] steps; the dependency graph is probably cyclic with conflicting constraints" + if (++$iterations -gt ($maxIterations + (20 * $nodes.Count))) { + throw "Dependency resolution did not converge; repeated constraints or a cyclic dependency kept changing the selected versions" } $key = $queue.Dequeue() $node = $nodes[$key] @@ -131,6 +132,12 @@ function Resolve-PSDependLock { Write-Verbose "Re-resolving [$key]: version [$($node.Version)] no longer satisfies [$combined]" } + $stateKey = "$key`n$combined`n$($node.Version)" + if ($resolutionStates.ContainsKey($stateKey)) { + throw "Dependency resolution for [$key] repeated the same unsatisfied state; the resolver is greedy and does not backtrack to older parent versions" + } + $resolutionStates[$stateKey] = $true + $template = $node.Template $probe = [PSCustomObject]@{ PSTypeName = 'PSDepend.Dependency' @@ -162,6 +169,9 @@ function Resolve-PSDependLock { if ($resolved.Count -ne 1 -or [string]::IsNullOrEmpty($resolved[0].Version)) { throw "Cannot lock [$key] with constraint [$combined]: the [$($node.DependencyType)] DependencyScript did not return a resolved version" } + if (-not (Test-PSDependExactVersion -Version ([string]$resolved[0].Version))) { + throw "Cannot lock [$key] with constraint [$combined]: the DependencyScript returned non-exact version [$($resolved[0].Version)]" + } $result = $resolved[0] # Drop the constraints this node imposed on its previous children @@ -173,13 +183,14 @@ function Resolve-PSDependLock { $node.Dependencies = @{} if ($result.Dependencies) { foreach ($childName in $result.Dependencies.Keys) { - $node.Dependencies[$childName] = Get-Requested $result.Dependencies[$childName] + $node.Dependencies[$childName] = Get-PSDependRequestedVersion -Version $result.Dependencies[$childName] } } Write-Verbose "Locked [$key] at [$($node.Version)] with [$($node.Dependencies.Count)] dependencies" foreach ($childName in $node.Dependencies.Keys) { $childKey = "$($node.DependencyType)::$childName" + $childContextHash = Get-PSDependResolutionContext -Dependency $template if (-not $nodes.ContainsKey($childKey)) { $nodes[$childKey] = @{ Name = $childName @@ -187,8 +198,11 @@ function Resolve-PSDependLock { Version = $null Dependencies = @{} Template = $template + ContextHash = $childContextHash Constraints = @{} } + } elseif ($nodes[$childKey].ContextHash -ne $childContextHash) { + throw "Cannot lock [$childKey] from different resolution sources or parameters; declare a single source and parameter set for each DependencyType::Name" } $nodes[$childKey].Constraints["node:$key"] = $node.Dependencies[$childName] $queue.Enqueue($childKey) diff --git a/PSDepend/Private/Test-PSDependExactVersion.ps1 b/PSDepend/Private/Test-PSDependExactVersion.ps1 new file mode 100644 index 0000000..95393c0 --- /dev/null +++ b/PSDepend/Private/Test-PSDependExactVersion.ps1 @@ -0,0 +1,25 @@ +function Test-PSDependExactVersion { + <# + .SYNOPSIS + Test whether a value is a concrete package version. + + .DESCRIPTION + Accepts semantic versions, including prerelease labels, and four-part + System.Version values. Rejects ranges, tags, URLs and package-manager specs. + + .PARAMETER Version + Version text to validate. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()] + [string]$Version + ) + + if ([string]::IsNullOrWhiteSpace($Version)) { return $false } + [System.Management.Automation.SemanticVersion]$semanticVersion = $null + [System.Version]$systemVersion = $null + [System.Management.Automation.SemanticVersion]::TryParse($Version, [ref]$semanticVersion) -or + [System.Version]::TryParse($Version, [ref]$systemVersion) +} diff --git a/PSDepend/Private/Test-VersionEquality.ps1 b/PSDepend/Private/Test-VersionEquality.ps1 index 6d5f90a..a45c28a 100644 --- a/PSDepend/Private/Test-VersionEquality.ps1 +++ b/PSDepend/Private/Test-VersionEquality.ps1 @@ -6,8 +6,8 @@ .DESCRIPTION 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 + SemanticVersion first (honoring prerelease labels), falls back to a + normalized 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 @@ -47,7 +47,7 @@ return $false } - # Equality is the zero case of the shared ordering primitive. Compare-Version - # handles SemanticVersion, normalised System.Version, and string fallback. + # Equality is the zero case of the shared Compare-Version ordering primitive. + # It handles SemanticVersion, normalized System.Version, and string fallback. return (Compare-Version -ReferenceVersion $ReferenceVersion -DifferenceVersion $DifferenceVersion) -eq 0 } diff --git a/PSDepend/Public/Get-Dependency.ps1 b/PSDepend/Public/Get-Dependency.ps1 index 8c17737..1ef5d5a 100644 --- a/PSDepend/Public/Get-Dependency.ps1 +++ b/PSDepend/Public/Get-Dependency.ps1 @@ -122,18 +122,22 @@ function Get-Dependency { } .PARAMETER IgnoreLock - Skip any .lock.json next to a dependency file and return the dependencies exactly as declared. + Skip any .lock.json next to a DependencyFile and return Dependencies as declared. - By default, when a lock written by Update-PSDependLock exists, each locked dependency's Version is - replaced with the locked version and locked transitive packages are returned as additional - dependencies (named Name@Version) that the declaring dependency DependsOn. A lock that no longer - matches its dependency file is an error. + By default, a lock pins root Versions and adds transitive packages as + Prerequisites. Their names normally use Name@Version; duplicate installation + contexts receive a #RootName suffix. Stale locks are errors. .EXAMPLE Get-Dependency -Path C:\requirements.psd1 Get dependencies defined in C:\requirements.psd1 + .EXAMPLE + Get-Dependency -Path .\requirements.psd1 -IgnoreLock + + Return declared ranges without applying requirements.lock.json + .LINK https://github.com/PowerShellOrg/PSDepend #> @@ -438,10 +442,11 @@ function Get-Dependency { $FileDependencies = @( Parse-Dependency -ParamSet $PSCmdlet.ParameterSetName ) $LockPath = Get-PSDependLockPath -DependencyFile $DependencyFile - if (-not $IgnoreLock -and $FileDependencies.Count -gt 0 -and (Test-Path -LiteralPath $LockPath -PathType Leaf)) { + if (-not $IgnoreLock -and (Test-Path -LiteralPath $LockPath -PathType Leaf)) { Write-Verbose "Applying lock [$LockPath] to [$DependencyFile]" $Lock = Import-PSDependLock -Path $LockPath - Merge-PSDependLock -Dependency $FileDependencies -Lock $Lock -LockPath $LockPath + Merge-PSDependLock -Dependency $FileDependencies -Lock $Lock -LockPath $LockPath ` + -DependencyFile $DependencyFile } else { $FileDependencies } diff --git a/PSDepend/Public/Invoke-PSDepend.ps1 b/PSDepend/Public/Invoke-PSDepend.ps1 index 06134cd..6cb1343 100644 --- a/PSDepend/Public/Invoke-PSDepend.ps1 +++ b/PSDepend/Public/Invoke-PSDepend.ps1 @@ -82,11 +82,11 @@ function Invoke-PSDepend { } .PARAMETER IgnoreLock - Skip any .lock.json next to a dependency file and resolve versions as declared. + Skip any .lock.json next to a DependencyFile and use versions as declared. - By default, a lock written by Update-PSDependLock pins each dependency to its locked version - and installs locked transitive packages first. A lock that no longer matches its dependency - file is an error; run Update-PSDependLock to refresh it. + By default, a lock written by Update-PSDependLock pins roots and transitive + packages. Install actions install children first. With -Test, the locked + root and transitive versions are tested. Stale locks are errors. .EXAMPLE Invoke-PSDepend @@ -98,6 +98,11 @@ function Invoke-PSDepend { # Install dependencies from require.psd1 + .EXAMPLE + Invoke-PSDepend -Path .\requirements.psd1 -IgnoreLock + + # Ignore requirements.lock.json and install versions as declared + .EXAMPLE Invoke-PSDepend -Path C:\Requirements -Recurse $False diff --git a/PSDepend/Public/Update-PSDependLock.ps1 b/PSDepend/Public/Update-PSDependLock.ps1 index 4507e83..5945b4a 100644 --- a/PSDepend/Public/Update-PSDependLock.ps1 +++ b/PSDepend/Public/Update-PSDependLock.ps1 @@ -1,28 +1,30 @@ function Update-PSDependLock { <# .SYNOPSIS - Resolve a DependencyFile's full dependency graph and write a lock file + Resolve a DependencyFile's full dependency graph and write a lock file. .DESCRIPTION Resolve a DependencyFile's full dependency graph and write a lock file Works like npm's package-lock.json: every dependency whose DependencyType - supports the Resolve action is resolved to the highest version that - satisfies its Version (exact, 'latest', or a NuGet range), its own - dependencies are resolved the same way recursively, and the result is - written next to the DependencyFile as .lock.json - (requirements.psd1 -> requirements.lock.json). - - A package required by several dependencies is locked to one version that - satisfies all of their constraints; conflicting constraints fail the update. - - Once a lock exists, Invoke-PSDepend and Get-Dependency use it automatically: - each dependency installs at its locked version and locked transitive - packages install first. If the DependencyFile changes, the lock is reported - as out of date until you run Update-PSDependLock again (or pass -IgnoreLock). - - DependencyTypes without a Resolve action (Git, GitHub, FileDownload, ...) are - recorded in the lock so drift is detected, but install exactly as before. + supports Resolve is resolved to the highest version satisfying Version, + and its dependencies are resolved recursively. Gallery, NuGet and + Chocolatey types accept NuGet ranges; Npm accepts npm semver ranges. + + One version is locked per DependencyType::Name. Resolution is greedy: + child constraints are intersected, but PSDepend does not backtrack to an + older parent version. Narrow a parent range when an older version is + required. Incompatible constraints fail the update. + + The result is written next to the DependencyFile as .lock.json + (requirements.psd1 -> requirements.lock.json). Invoke-PSDepend and + Get-Dependency then use it automatically. A changed Dependency, Version, + resolution Source, or DependencyScript parameter makes the lock stale. + + DependencyTypes without Resolve (Git, GitHub, FileDownload, ...) are + recorded for drift detection but install exactly as declared. Lock files + should be committed and reviewed like code; format version 1 validates + exact versions but does not contain package content hashes. See Get-Help about_PSDepend for more information. diff --git a/PSDepend/en-US/about_PSDepend.help.txt b/PSDepend/en-US/about_PSDepend.help.txt index 4bad757..f3906f8 100644 --- a/PSDepend/en-US/about_PSDepend.help.txt +++ b/PSDepend/en-US/about_PSDepend.help.txt @@ -113,24 +113,28 @@ DETAILED DESCRIPTION Update-PSDependLock -Path .\requirements.psd1 # writes requirements.lock.json Invoke-PSDepend -Path .\requirements.psd1 # installs the locked versions - Update-PSDependLock asks each DependencyType that supports the Resolve - action (PSGalleryModule, PSResourceGet, PSGalleryNuget, Nuget, Chocolatey, - Npm) for the highest version satisfying the declared Version and walks that - package's own dependencies the same way. One version is locked per - DependencyType::Name; conflicting constraints fail the update. Npm pins only - the declared package; npm's package-lock.json governs its subtree. - - When a lock exists next to a dependency file, Invoke-PSDepend and - Get-Dependency use it automatically: dependencies install at their locked - version and locked transitive packages (returned as Name@Version - dependencies) install first. A lock that no longer matches its dependency - file is an error until Update-PSDependLock is run again; -IgnoreLock - resolves without it. DependencyTypes without Resolve install as declared. - - A DependencyScript opts in by accepting 'Resolve' in PSDependAction and, - for that action, emitting one PSDepend.ResolvedDependency object - (Name, exact Version, Dependencies hashtable of Name -> NuGet range or - 'latest') after querying its source, without installing anything. + Update-PSDependLock asks each DependencyType that supports Resolve for the + highest version satisfying Version and walks direct dependencies recursively. + Gallery, NuGet and Chocolatey types use NuGet ranges; Npm uses npm semver + ranges and pins only the declared package. npm's package-lock.json governs + its subtree. + + One version is locked per DependencyType::Name. Resolution is greedy: child + constraints are intersected, but PSDepend does not backtrack to an older + parent. Narrow the parent range when an older version is required. + + Invoke-PSDepend and Get-Dependency apply a neighboring lock automatically. + Locked transitive packages install first and are materialized once per root + installation context. A changed Dependency, Version, resolution Source, or + DependencyScript parameter makes the lock stale; -IgnoreLock opts out. With + -Test, locked roots and transitive packages are tested. + + Commit and review locks like code. PSDepend validates structure and exact + versions before use, but lock format version 1 has no package content hashes. + + A DependencyScript opts in by accepting Resolve in PSDependAction and emits + one PSDepend.ResolvedDependency (Name, exact Version, direct Dependencies) + after querying its source without installing anything. Extending PSDepend ================== diff --git a/README.md b/README.md index 77505e2..2497a75 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,15 @@ Update-PSDependLock -Path .\requirements.psd1 # writes requirements.lock.json Invoke-PSDepend -Path .\requirements.psd1 # installs the locked versions ``` -`Update-PSDependLock` asks each dependency type that supports the `Resolve` action (`PSGalleryModule`, `PSResourceGet`, `PSGalleryNuget`, `Nuget`, `Chocolatey`, `Npm`) for the highest version that satisfies the declared `Version` (exact, `latest`, or a NuGet range) and walks that package's own dependencies the same way. A package required by several dependencies is locked to one version that satisfies all of their constraints; conflicting constraints fail the update. `Npm` pins only the declared package — npm's own `package-lock.json` governs its subtree. +`Update-PSDependLock` asks each dependency type that supports the `Resolve` action (`PSGalleryModule`, `PSResourceGet`, `PSGalleryNuget`, `Nuget`, `Chocolatey`, `Npm`) for the highest version that satisfies the declared `Version` and walks that package's own dependencies. The gallery, NuGet, and Chocolatey types accept NuGet ranges; `Npm` accepts npm semver ranges such as `^1.2.0` or `>=1 <2` and rejects NuGet range syntax. `Npm` pins only the declared package because npm's own `package-lock.json` governs its subtree. -Once a lock exists next to a dependency file, `Invoke-PSDepend` and `Get-Dependency` use it automatically: each dependency installs at its locked version, and locked transitive packages install first. If the dependency file changes (a version constraint edited, a dependency added or removed), the lock is reported as out of date until you run `Update-PSDependLock` again. Pass `-IgnoreLock` to resolve without it. Commit the `.lock.json` alongside the dependency file. +One version is locked per `DependencyType::Name`. Resolution is greedy: after selecting a parent version, PSDepend intersects child constraints but does not backtrack to an older parent version. Narrow the parent's range if an older version is required. Packages reached from roots with different installation contexts are installed once per root. + +Once a lock exists next to a dependency file, `Invoke-PSDepend` and `Get-Dependency` use it automatically. Locked transitive packages install first. A changed dependency, version, resolution source, or DependencyScript parameter makes the lock out of date; removing every dependency does too. Pass `-IgnoreLock` to use the DependencyFile without the lock. With `Invoke-PSDepend -Test`, locked root and transitive versions are tested. + +Commit the `.lock.json` alongside the DependencyFile and review lock changes like code. PSDepend validates its structure and exact versions before use, but version 1 does not contain package content hashes. + +The JSON contains `lockfileVersion`, `dependencies`, and `packages`. Root entries record the requested version, `DependencyType::Name` package key, and a hash of resolution Source/Parameters. Package entries record an exact `version` and direct dependency ranges. Synthesized transitive Dependency names normally use `Name@Version`; a `#RootName` suffix disambiguates a package installed for another root context. Dependency types without a `Resolve` action (`Git`, `GitHub`, `FileDownload`, ...) are recorded in the lock for drift detection but install exactly as declared. @@ -202,8 +208,8 @@ PSDepend is extensible. To add a new dependency type, create a script in the [PS Your script must: - Include comment-based help describing how it uses `Dependency` metadata -- Accept a `PSDependAction` parameter with values `Install`, `Test`, and/or `Import` -- Implement the expected behavior for each action (`Install` installs, `Test` returns a boolean, `Import` loads the dependency) +- Accept a `PSDependAction` parameter with any actions it implements (`Install`, `Test`, `Import`, and optionally `Resolve`) +- For `Resolve`, query only and emit one `PSDepend.ResolvedDependency` with an exact `Version` and direct `Dependencies`; do not install See [Git.ps1](https://github.com/PowerShellOrg/PSDepend/blob/main/PSDepend/PSDependScripts/Git.ps1) and [PSGalleryModule.ps1](https://github.com/PowerShellOrg/PSDepend/blob/main/PSDepend/PSDependScripts/PSGalleryModule.ps1) for reference implementations. diff --git a/Tests/Chocolatey.Type.Tests.ps1 b/Tests/Chocolatey.Type.Tests.ps1 index 7c3d5e5..58708ec 100644 --- a/Tests/Chocolatey.Type.Tests.ps1 +++ b/Tests/Chocolatey.Type.Tests.ps1 @@ -180,7 +180,7 @@ Describe 'Chocolatey script Resolve' { Context 'PSDependAction = Resolve' { - It 'latest picks the highest stable version and skips prerelease' { + It 'Latest picks the highest stable version and skips prerelease' { $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version 'latest' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve @@ -190,7 +190,7 @@ Describe 'Chocolatey script Resolve' { $result.Version | Should -Be '3.0.0' } - It 'range picks the highest stable in-range version, skipping prerelease' { + It 'Range picks the highest stable in-range version, skipping prerelease' { $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[2.0.0,3.0.0)' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve @@ -198,7 +198,7 @@ Describe 'Chocolatey script Resolve' { $result.Version | Should -Be '2.45.0' } - It 'converts the feed Dependencies string to a NuGet-range map' { + It 'Converts the feed Dependencies string to a NuGet-range map' { $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '2.44.0' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve @@ -210,7 +210,7 @@ Describe 'Chocolatey script Resolve' { $result.Dependencies['chocolatey-core.extension'] | Should -Be '[1.3.3,)' } - It 'converts a bare dependency version to a minimum-inclusive range' { + It 'Converts a bare dependency version to a minimum-inclusive range' { $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '3.0.0' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve @@ -219,7 +219,7 @@ Describe 'Chocolatey script Resolve' { $result.Dependencies['git.install'] | Should -Be '[3.0.0,)' } - It 'writes an error and emits nothing when no version satisfies' { + It 'Writes an error and emits nothing when no version satisfies' { $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version '[4.0.0,)' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err @@ -229,7 +229,7 @@ Describe 'Chocolatey script Resolve' { $result | Should -BeNullOrEmpty } - It 'writes an error and emits nothing when Source is not a feed URL' { + It 'Writes an error and emits nothing when Source is not a feed URL' { $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Source 'C:\LocalFeed' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err @@ -239,7 +239,21 @@ Describe 'Chocolatey script Resolve' { Should -Invoke -CommandName Find-NugetPackage -ModuleName PSDepend -Times 0 -Exactly } - It 'never invokes choco during Resolve' { + It 'Rejects an HTTP source when credentials would be transmitted' { + $credential = New-TestCredential -UserName 'feeduser' -Password 'feedpass' + $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' ` + -Source 'http://packages.example.test/api/v2/' -Credential $credential + + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err + $err[0].ToString() | Should -Match 'requires an HTTPS Source' + } + + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Find-NugetPackage -ModuleName PSDepend -Times 0 -Exactly + } + + It 'Never invokes choco during Resolve' { $dep = New-PSDependFixture -DependencyName 'git' -DependencyType 'Chocolatey' -Version 'latest' InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve diff --git a/Tests/Compare-Version.Tests.ps1 b/Tests/Compare-Version.Tests.ps1 index 4e9626e..30455d4 100644 --- a/Tests/Compare-Version.Tests.ps1 +++ b/Tests/Compare-Version.Tests.ps1 @@ -43,7 +43,7 @@ Describe 'Compare-Version' { } } - Context 'System.Version fallback and normalisation' { + Context 'System.Version fallback and normalization' { It 'Compares four-part versions SemVer rejects' { InModuleScope PSDepend { diff --git a/Tests/Npm.Type.Tests.ps1 b/Tests/Npm.Type.Tests.ps1 index 3881b90..5802cf4 100644 --- a/Tests/Npm.Type.Tests.ps1 +++ b/Tests/Npm.Type.Tests.ps1 @@ -63,22 +63,37 @@ Describe 'Npm script' { } Context 'PSDependAction = Resolve' { - It 'Picks the highest version when npm returns several' { + It 'Picks the highest version satisfying an npm semver range' { InModuleScope PSDepend { - Mock Find-NodeModule { [string[]]@('0.1.0', '0.3.2', '0.2.9') } + Mock Find-NodeModule { [string[]]@('0.9.0', '0.10.0') } } - $dep = New-PSDependFixture -DependencyName 'left-pad' -DependencyType 'Npm' -Version '[0.1.0,0.4.0)' + $dep = New-PSDependFixture -DependencyName 'left-pad' -DependencyType 'Npm' -Version '>=0.9.0 <0.11.0' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { & $ScriptPath -Dependency $Dep -PSDependAction Resolve } $result.Name | Should -Be 'left-pad' - $result.Version | Should -Be '0.3.2' + $result.Version | Should -Be '0.10.0' $result.Dependencies.Count | Should -Be 0 Should -Invoke -CommandName Find-NodeModule -ModuleName PSDepend -Times 1 -Exactly -ParameterFilter { - $PackageName -eq 'left-pad' -and $Version -eq '[0.1.0,0.4.0)' + $PackageName -eq 'left-pad' -and $Version -eq '>=0.9.0 <0.11.0' } } + It 'Rejects NuGet range syntax before querying npm' { + InModuleScope PSDepend { + Mock Find-NodeModule { throw 'Find-NodeModule must not run for invalid syntax' } + } + $dep = New-PSDependFixture -DependencyName 'left-pad' -DependencyType 'Npm' -Version '[0.1.0,0.4.0)' + + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err + $err | Should -Not -BeNullOrEmpty + } + + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Find-NodeModule -ModuleName PSDepend -Times 0 + } + It 'Returns the single version npm reports for latest' { InModuleScope PSDepend { Mock Find-NodeModule { [string[]]@('1.3.0') } diff --git a/Tests/Nuget.Type.Tests.ps1 b/Tests/Nuget.Type.Tests.ps1 index badaf90..47e7a53 100644 --- a/Tests/Nuget.Type.Tests.ps1 +++ b/Tests/Nuget.Type.Tests.ps1 @@ -103,6 +103,7 @@ Describe 'Nuget script' { @( [PSCustomObject]@{ Version = '1.9.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = '' } } [PSCustomObject]@{ Version = '2.5.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'System.Memory:4.5.4:net45|Foo::|::netstandard2.0' } } + [PSCustomObject]@{ Version = '2.9.0-beta1'; Properties = @{ IsPrerelease = 'true'; Dependencies = '' } } [PSCustomObject]@{ Version = '3.0.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'System.Memory:[4.5.4, ):' } } [PSCustomObject]@{ Version = '3.1.0-beta1'; Properties = @{ IsPrerelease = 'true'; Dependencies = '' } } ) diff --git a/Tests/PSDependLock.Tests.ps1 b/Tests/PSDependLock.Tests.ps1 index 9d7ad82..6b04e4b 100644 --- a/Tests/PSDependLock.Tests.ps1 +++ b/Tests/PSDependLock.Tests.ps1 @@ -1,3 +1,4 @@ +# cspell:ignore installignore nomatch #requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { @@ -69,6 +70,24 @@ Describe 'Update-PSDependLock' { @($lock.packages.PSObject.Properties.Name).Count | Should -Be 4 } + It 'Writes byte-identical output when resolution has not changed' { + $file = Initialize-LockProject -Name 'stable-output' -Body $script:AppBody + $lockPath = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath -PassThru + $first = Get-Content -LiteralPath $lockPath -Raw + + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + + Get-Content -LiteralPath $lockPath -Raw | Should -BeExactly $first + } + + It 'Does not write a lock with WhatIf' { + $file = Initialize-LockProject -Name 'whatif' -Body $script:AppBody + + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath -WhatIf + + Test-Path (Join-Path (Split-Path $file) 'requirements.lock.json') | Should -BeFalse + } + It 'Records dependencies whose type cannot Resolve without a resolved package' { $file = Initialize-LockProject -Name 'unresolvable' -Body $script:AppBody $lockPath = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath -PassThru @@ -86,7 +105,8 @@ Describe 'Update-PSDependLock' { Util = @{ DependencyType = 'FakeResolver'; Version = 'latest' } } '@ - { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | Should -Throw -ExpectedMessage '*FakeResolver::Lib*' + { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | + Should -Throw -ExpectedMessage '*FakeResolver::Lib*requires*' Test-Path (Join-Path (Split-Path $file) 'requirements.lock.json') | Should -BeFalse } @@ -96,7 +116,76 @@ Describe 'Update-PSDependLock' { App = @{ DependencyType = 'FakeResolver'; Version = '[5.0,)' } } '@ - { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | Should -Throw -ExpectedMessage '*FakeResolver::App*' + { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | + Should -Throw -ExpectedMessage '*FakeResolver::App*constraint*' + } +} + +Describe 'Import-PSDependLock validation' { + + It 'Rejects malformed JSON' { + $path = Join-Path $TestDrive 'malformed.lock.json' + Set-Content -LiteralPath $path -Value '{' + + InModuleScope PSDepend -Parameters @{ Path = $path } { + { Import-PSDependLock -Path $Path } | Should -Throw -ExpectedMessage '*not valid JSON*' + } + } + + It 'Rejects a JSON array instead of a lock object' { + $path = Join-Path $TestDrive 'array.lock.json' + Set-Content -LiteralPath $path -Value '[]' + + InModuleScope PSDepend -Parameters @{ Path = $path } { + { Import-PSDependLock -Path $Path } | Should -Throw -ExpectedMessage '*not a PSDepend lock file*' + } + } + + It 'Rejects an unsupported lock format' { + $path = Join-Path $TestDrive 'newer.lock.json' + Set-Content -LiteralPath $path -Value '{"lockfileVersion":2,"dependencies":{},"packages":{}}' + + InModuleScope PSDepend -Parameters @{ Path = $path } { + { Import-PSDependLock -Path $Path } | Should -Throw -ExpectedMessage '*supports lockfileVersion 1*' + } + } + + It 'Rejects a root that resolves to a different package' { + $path = Join-Path $TestDrive 'redirect.lock.json' + Set-Content -LiteralPath $path -Value @' +{ + "lockfileVersion": 1, + "dependencies": { + "App": { "dependencyType": "Npm", "name": "App", "requested": "latest", "resolved": "Npm::Injected" } + }, + "packages": { + "Npm::Injected": { "version": "1.0.0", "dependencies": {} } + } +} +'@ + + InModuleScope PSDepend -Parameters @{ Path = $path } { + { Import-PSDependLock -Path $Path } | Should -Throw -ExpectedMessage '*resolved key*' + } + } + + It 'Rejects a locked version that is not an exact package version' { + $path = Join-Path $TestDrive 'unsafe-version.lock.json' + Set-Content -LiteralPath $path -Value @' +{ + "lockfileVersion": 1, + "dependencies": { + "App": { "dependencyType": "Npm", "name": "App", "requested": "latest", "resolved": "Npm::App" } + }, + "packages": { + "Npm::App": { "version": "https://example.invalid/package.tgz", "dependencies": {} } + } +} +'@ + + InModuleScope PSDepend -Parameters @{ Path = $path } { + { Import-PSDependLock -Path $Path } | Should -Throw -ExpectedMessage '*exact version*' + } } } @@ -112,7 +201,7 @@ Describe 'Get-Dependency with a lock' { ($deps | Where-Object DependencyName -eq 'App').Version | Should -Be '1.1.0' } - It 'Materialises locked transitive packages as dependencies that install before their parent' { + It 'Materializes locked transitive packages as dependencies that install before their parent' { $deps = @(Get-Dependency -Path $script:LockedFile) $names = $deps.DependencyName $names | Should -Contain 'Lib@1.5.0' @@ -158,6 +247,23 @@ Describe 'Get-Dependency with a lock' { { Get-Dependency -Path $file } | Should -Throw -ExpectedMessage '*`[Extra`] is not in the lock*' } + + It 'Fails when the dependency file has removed every dependency in the lock' { + $file = Initialize-LockProject -Name 'empty' -Body $script:AppBody + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + Set-Content -LiteralPath $file -Value '@{}' + + { Get-Dependency -Path $file } | Should -Throw -ExpectedMessage '*is in the lock but not in the DependencyFile*' + } + + It 'Fails when the source used for resolution changes' { + $body = $script:AppBody -replace "Target = '", "Source = 'feed-a'`n Target = '" + $file = Initialize-LockProject -Name 'source' -Body $body + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + Set-Content -LiteralPath $file -Value ($body -replace "Source = 'feed-a'", "Source = 'feed-b'") + + { Get-Dependency -Path $file } | Should -Throw -ExpectedMessage '*resolution source or parameters changed*' + } } Describe 'Invoke-PSDepend with a lock' { @@ -172,6 +278,29 @@ Describe 'Invoke-PSDepend with a lock' { $log | Should -Be @('Core@2.0.0', 'Lib@1.5.0', 'Util@2.0.0', 'App@1.1.0') } + It 'Installs a shared child into each root target' { + $file = Initialize-LockProject -Name 'targets' -Body @' +@{ + App = @{ + DependencyType = 'FakeResolver' + Version = '1.0.0' + Target = '$DependencyFolder/app-target' + } + Util = @{ + DependencyType = 'FakeResolver' + Version = '1.0.0' + Target = '$DependencyFolder/util-target' + } +} +'@ + $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath + + Invoke-PSDepend -Path $file -PSDependTypePath $script:MapPath -Force -WarningAction SilentlyContinue + + Get-Content (Join-Path (Split-Path $file) 'app-target/installed.log') | Should -Contain 'Lib@1.5.0' + Get-Content (Join-Path (Split-Path $file) 'util-target/installed.log') | Should -Contain 'Lib@1.5.0' + } + It 'Passes the declared range through with -IgnoreLock' { $file = Initialize-LockProject -Name 'installignore' -Body $script:AppBody $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath diff --git a/Tests/PSGalleryModule.Type.Tests.ps1 b/Tests/PSGalleryModule.Type.Tests.ps1 index 4152342..f13c310 100644 --- a/Tests/PSGalleryModule.Type.Tests.ps1 +++ b/Tests/PSGalleryModule.Type.Tests.ps1 @@ -295,6 +295,7 @@ Describe 'PSGalleryModule script' { $result.Dependencies['BuildHelpers'] | Should -Be '2.0.1' Should -Invoke -CommandName Install-Module -ModuleName PSDepend -Times 0 Should -Invoke -CommandName Save-Module -ModuleName PSDepend -Times 0 + Should -Invoke -CommandName Get-PackageProvider -ModuleName PSDepend -Times 0 } It 'Resolves latest to the highest available version with an empty dependency map' { diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index 1298497..f33c0af 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -146,6 +146,7 @@ Describe 'PSGalleryNuget script' { @( [PSCustomObject]@{ Version = '1.9.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = '' } } [PSCustomObject]@{ Version = '2.5.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'PSDeploy:0.2.5:|BuildHelpers:[2.0.0, ):' } } + [PSCustomObject]@{ Version = '2.9.0-beta1'; Properties = @{ IsPrerelease = 'true'; Dependencies = '' } } [PSCustomObject]@{ Version = '3.0.0'; Properties = @{ IsPrerelease = 'false'; Dependencies = 'BuildHelpers::' } } [PSCustomObject]@{ Version = '3.1.0-beta1'; Properties = @{ IsPrerelease = 'true'; Dependencies = '' } } ) diff --git a/Tests/PSResourceGet.Type.Tests.ps1 b/Tests/PSResourceGet.Type.Tests.ps1 index 9f15cbd..07bd70e 100644 --- a/Tests/PSResourceGet.Type.Tests.ps1 +++ b/Tests/PSResourceGet.Type.Tests.ps1 @@ -319,8 +319,8 @@ Describe 'PSResourceGet script' { Context 'PSDependAction = Resolve' { BeforeAll { # Build child dependencies from the real PSResourceGet types when the module is installed - # so the VersionRange.ToString() normalisation is exercised for real; otherwise fall back - # to objects with the same property names whose ToString() yields the normalised form. + # so the VersionRange.ToString() normalization is exercised for real; otherwise fall back + # to objects with the same property names whose ToString() yields the normalized form. Import-Module Microsoft.PowerShell.PSResourceGet -ErrorAction SilentlyContinue function script:New-ResolveDependency { param([string]$Name, [string]$Range) @@ -333,8 +333,12 @@ Describe 'PSResourceGet script' { $parsed = $rangeType.GetMethod('Parse', [type[]]@([string])).Invoke($null, @($Range)) return $depType::new($Name, $parsed) } + if (-not $Range) { + return [PSCustomObject]@{ Name = $Name; VersionRange = $null } + } $normalized = @{ '4.9.0' = '[4.9.0, )'; '(, )' = '(, )' }[$Range] - $range = if ($Range) { [PSCustomObject]@{ Normalized = $normalized } | Add-Member ScriptMethod ToString { $this.Normalized } -Force -PassThru } else { $null } + $range = [PSCustomObject]@{ Normalized = $normalized } + $range = $range | Add-Member ScriptMethod ToString { $this.Normalized } -Force -PassThru [PSCustomObject]@{ Name = $Name; VersionRange = $range } } diff --git a/Tests/Test-VersionEquality.Tests.ps1 b/Tests/Test-VersionEquality.Tests.ps1 index 6747bc9..9344365 100644 --- a/Tests/Test-VersionEquality.Tests.ps1 +++ b/Tests/Test-VersionEquality.Tests.ps1 @@ -1,5 +1,4 @@ -# cspell:ignore normalisation normalises -#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { if (-not $env:BHProjectPath) { @@ -131,8 +130,8 @@ Describe 'Test-VersionEquality' { Context 'Tricky versions with zero components or zero-prefixed pre-release' { # 0.0.0.5 — [System.Version] Build=0 Revision=5 - # Risk: Math.Max(Build,0) normalises absent build (-1) to 0, - # so 0.0.0.5 must NOT equal 0.0.0 even though both have Build→0 after normalisation. + # Risk: Math.Max(Build,0) normalizes absent build (-1) to 0, + # so 0.0.0.5 must NOT equal 0.0.0 even though both have Build→0 after normalization. It 'Returns true for 0.0.0.5 equal to itself' { InModuleScope PSDepend { Test-VersionEquality -ReferenceVersion '0.0.0.5' -DifferenceVersion '0.0.0.5' diff --git a/adr/0002-lock-resolution-model.md b/adr/0002-lock-resolution-model.md new file mode 100644 index 0000000..53402bd --- /dev/null +++ b/adr/0002-lock-resolution-model.md @@ -0,0 +1,49 @@ +# Lock resolution uses a flat, greedy graph + +## Context + +PSDepend DependencyScripts install several package ecosystems. A Lock needs a +portable identity and resolution model without reproducing every package +manager's native graph algorithm. + +## Decision + +A Lock belongs to one DependencyFile and uses `DependencyType::Name` as package +identity. It records one exact version for that identity across the file. +Resolution selects the highest version satisfying the constraints currently +known, intersects constraints from every parent, and re-resolves invalidated +children until reaching a fixed point. It does not backtrack to an older parent +version. Users must narrow a parent range when greedy selection hides a valid +older solution. + +Source and DependencyScript Parameters affect resolution. Root entries store a +SHA-256 fingerprint of that context, without exposing its values, and a changed +fingerprint makes the Lock stale. Two roots cannot resolve the same package from +different contexts in one Lock. + +Target, Tags, and other installation metadata do not affect version selection. +When applying a Lock, transitive packages are therefore materialized separately +for each root Dependency. Their DependencyName is normally `Name@Version`; an +additional root context receives a `#RootName` suffix to keep names unique. + +A DependencyScript opts in by accepting the `Resolve` PSDependAction. Resolve +runs alone, must only query its source, and emits exactly one +`PSDepend.ResolvedDependency` containing Name, an exact Version, and a hashtable +of direct dependency names to ranges. Npm ranges pass through as npm semver; +multiple constraints use the shared NuGet intersection logic, so incompatible +cross-parent npm ranges fail rather than being reinterpreted. + +Lock format version 1 validates object shape, package references, names, and +exact versions before consumption. It does not record artifact URLs or content +hashes. A committed Lock must therefore be reviewed like code. + +## Consequences + +- Installation is deterministic for the exact versions recorded in the Lock. +- A graph with a valid solution can still fail if finding it requires parent + backtracking. +- The same transitive package may be installed more than once when roots have + different installation contexts. +- A changed Source or Parameters requires `Update-PSDependLock`. +- Native integrity verification remains the responsibility of each package + manager until a later lock format records artifact hashes. diff --git a/cspell.json b/cspell.json index 7f2ff3b..da1a8dd 100644 --- a/cspell.json +++ b/cspell.json @@ -8,11 +8,17 @@ ], "words": [ "BHPS", + "Depen", "RSAT", "choco", + "hashtables", + "inclusivity", "psake", "nuget", - "PSDepend" + "netstandard", + "Newtonsoft", + "PSDepend", + "unparseable" ], "ignoreWords": [ "Creds", diff --git a/docs/en-US/Get-Dependency.md b/docs/en-US/Get-Dependency.md index d7286a6..d65d113 100644 --- a/docs/en-US/Get-Dependency.md +++ b/docs/en-US/Get-Dependency.md @@ -133,10 +133,10 @@ Accept wildcard characters: False ### -IgnoreLock -Skip any `.lock.json` next to a dependency file and return the dependencies exactly as declared. -By default an existing lock (see `Update-PSDependLock`) pins each locked dependency's Version and adds the -locked transitive packages as additional `Name@Version` dependencies; a lock that no longer matches its -dependency file is an error. +Skip any `.lock.json` next to a dependency file and return the Dependencies as declared. +By default an existing lock pins root versions and adds locked transitive packages as Prerequisites. +Their names normally use `Name@Version`; duplicate installation contexts receive a `#RootName` suffix. +A changed dependency, version, resolution source, or DependencyScript parameter makes the lock stale. ```yaml Type: SwitchParameter diff --git a/docs/en-US/Invoke-DependencyScript.md b/docs/en-US/Invoke-DependencyScript.md index 7f2c23a..0fabc89 100644 --- a/docs/en-US/Invoke-DependencyScript.md +++ b/docs/en-US/Invoke-DependencyScript.md @@ -20,8 +20,8 @@ Invoke-DependencyScript -Dependency [-PSDependTypePath ] [-PS ## DESCRIPTION -Low-level function that invokes the script for a specific dependency type and action -(Test, Install, or Import). Typically called by Invoke-PSDepend rather than directly. +Low-level function that invokes the DependencyScript for a specific DependencyType and action +(Test, Install, Import, or Resolve). Typically called by Invoke-PSDepend rather than directly. ## EXAMPLES @@ -69,8 +69,8 @@ Accept wildcard characters: False ### -PSDependAction -The action to invoke: Test, Install, or Import. - +The action to invoke: Test, Install, Import, or Resolve. Resolve must run alone; a supporting +DependencyScript queries its source and returns one exact `PSDepend.ResolvedDependency` without installing. ```yaml Type: String[] Parameter Sets: (All) diff --git a/docs/en-US/Invoke-PSDepend.md b/docs/en-US/Invoke-PSDepend.md index 415e0bd..fad3abe 100644 --- a/docs/en-US/Invoke-PSDepend.md +++ b/docs/en-US/Invoke-PSDepend.md @@ -316,9 +316,9 @@ Accept wildcard characters: False ### -IgnoreLock -Skip any `.lock.json` next to a dependency file and resolve versions as declared. By default a lock -written by `Update-PSDependLock` pins each dependency to its locked version and installs locked transitive -packages first; a lock that no longer matches its dependency file is an error. +Skip any `.lock.json` next to a dependency file and use versions as declared. By default a lock +pins roots and transitive packages. With `-Test`, those locked versions are tested rather than installed. +A changed dependency, version, resolution source, or DependencyScript parameter makes the lock stale. ```yaml Type: SwitchParameter diff --git a/docs/en-US/Update-PSDependLock.md b/docs/en-US/Update-PSDependLock.md index 22abaf4..1ba45d0 100644 --- a/docs/en-US/Update-PSDependLock.md +++ b/docs/en-US/Update-PSDependLock.md @@ -21,21 +21,24 @@ Update-PSDependLock [[-Path] ] [-Recurse ] [-PSDependTypePath ## DESCRIPTION -Works like npm's package-lock.json. Every dependency whose type supports the `Resolve` action +Works like npm's package-lock.json. Every dependency whose type supports `Resolve` (`PSGalleryModule`, `PSResourceGet`, `PSGalleryNuget`, `Nuget`, `Chocolatey`, `Npm`) is resolved to the -highest version that satisfies its Version (exact, `latest`, or a NuGet range); its own dependencies are -resolved the same way recursively, and the result is written next to the dependency file as -`.lock.json` (`requirements.psd1` -> `requirements.lock.json`). - -A package required by several dependencies is locked to one version that satisfies all of their -constraints; conflicting constraints fail the update. `Npm` pins only the declared package and leaves -its subtree to npm's own package-lock.json. - -Once a lock exists, `Invoke-PSDepend` and `Get-Dependency` use it automatically: each dependency -installs at its locked version and locked transitive packages install first. If the dependency file -changes, the lock is reported as out of date until you run `Update-PSDependLock` again (or pass -`-IgnoreLock`). Dependency types without a `Resolve` action are recorded in the lock so drift is -detected, but install exactly as declared. +highest version satisfying its Version, and its dependencies are resolved recursively. The gallery, +NuGet, and Chocolatey types accept NuGet ranges. `Npm` accepts npm semver ranges and rejects NuGet syntax; +it pins only the declared package because npm's own `package-lock.json` governs its subtree. + +One version is locked per `DependencyType::Name`. Resolution is greedy: PSDepend intersects child +constraints but does not backtrack to an older parent version. Narrow the parent's range when an older +version is required. Packages reached from roots with different installation contexts are installed once +per root. + +The lock is written next to the dependency file as `.lock.json`. `Invoke-PSDepend` and +`Get-Dependency` use it automatically. A changed dependency, version, resolution source, or +DependencyScript parameter makes it out of date; removing every dependency does too. Dependency types +without `Resolve` are recorded for drift detection but install exactly as declared. + +Commit and review locks like code. Format version 1 validates its structure and exact versions before +use, but does not contain package content hashes. ## EXAMPLES From c05a8111c83416f23041c3d492bf6c355fef63b2 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 30 Sep 2026 21:45:55 +0000 Subject: [PATCH 3/6] test: make lock coverage CI portable --- Tests/PSDependLock.Tests.ps1 | 14 ++++++++++++-- Tests/PSResourceGet.Type.Tests.ps1 | 4 +--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Tests/PSDependLock.Tests.ps1 b/Tests/PSDependLock.Tests.ps1 index 6b04e4b..3486775 100644 --- a/Tests/PSDependLock.Tests.ps1 +++ b/Tests/PSDependLock.Tests.ps1 @@ -47,6 +47,16 @@ BeforeAll { Version = 'latest' } } +'@ + + $script:ResolvableBody = @' +@{ + App = @{ + DependencyType = 'FakeResolver' + Version = '[1.0,2.0)' + Target = '$DependencyFolder/target' + } +} '@ } @@ -269,7 +279,7 @@ Describe 'Get-Dependency with a lock' { Describe 'Invoke-PSDepend with a lock' { It 'Installs locked versions, children first' { - $file = Initialize-LockProject -Name 'install' -Body $script:AppBody + $file = Initialize-LockProject -Name 'install' -Body $script:ResolvableBody $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath Invoke-PSDepend -Path $file -PSDependTypePath $script:MapPath -Force -WarningAction SilentlyContinue @@ -302,7 +312,7 @@ Describe 'Invoke-PSDepend with a lock' { } It 'Passes the declared range through with -IgnoreLock' { - $file = Initialize-LockProject -Name 'installignore' -Body $script:AppBody + $file = Initialize-LockProject -Name 'installignore' -Body $script:ResolvableBody $null = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath Invoke-PSDepend -Path $file -PSDependTypePath $script:MapPath -Force -IgnoreLock -WarningAction SilentlyContinue diff --git a/Tests/PSResourceGet.Type.Tests.ps1 b/Tests/PSResourceGet.Type.Tests.ps1 index 07bd70e..6ba1273 100644 --- a/Tests/PSResourceGet.Type.Tests.ps1 +++ b/Tests/PSResourceGet.Type.Tests.ps1 @@ -337,9 +337,7 @@ Describe 'PSResourceGet script' { return [PSCustomObject]@{ Name = $Name; VersionRange = $null } } $normalized = @{ '4.9.0' = '[4.9.0, )'; '(, )' = '(, )' }[$Range] - $range = [PSCustomObject]@{ Normalized = $normalized } - $range = $range | Add-Member ScriptMethod ToString { $this.Normalized } -Force -PassThru - [PSCustomObject]@{ Name = $Name; VersionRange = $range } + [PSCustomObject]@{ Name = $Name; VersionRange = $normalized } } function script:New-ResolveCatalogue { From b4dd09f03e56514980d2ec7977e7158ccd4f3fbf Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Wed, 30 Sep 2026 22:01:25 +0000 Subject: [PATCH 4/6] fix: address lock review findings --- PSDepend/PSDependScripts/Nuget.ps1 | 4 +++ PSDepend/PSDependScripts/PSGalleryNuget.ps1 | 4 +++ PSDepend/Private/Resolve-PSDependLock.ps1 | 29 +++++++++++++++++++++ README.md | 2 +- Tests/Nuget.Type.Tests.ps1 | 14 ++++++++++ Tests/PSDependLock.Tests.ps1 | 13 +++++++++ Tests/PSGalleryNuget.Type.Tests.ps1 | 14 ++++++++++ Tests/Shared/FakeResolver.ps1 | 6 +++++ 8 files changed, 85 insertions(+), 1 deletion(-) diff --git a/PSDepend/PSDependScripts/Nuget.ps1 b/PSDepend/PSDependScripts/Nuget.ps1 index 81d4350..0929d53 100644 --- a/PSDepend/PSDependScripts/Nuget.ps1 +++ b/PSDepend/PSDependScripts/Nuget.ps1 @@ -100,6 +100,10 @@ if (-not $Dependency.Source) { $Credential = $Dependency.Credential if ($PSDependAction -contains 'Resolve') { + if ($Credential -and $Source -notmatch '^https://') { + Write-Error "Resolve for [$DependencyName] requires an HTTPS Source when Credential is supplied; got [$Source]" + return + } $packages = @(Find-NugetPackage -Name $DependencyName -PackageSourceUrl $Source -Credential $Credential) $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) $resolvedVersion = $null diff --git a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 index 5ca05d3..70de401 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -88,6 +88,10 @@ if (-not $Dependency.Source) { $Credential = $Dependency.Credential if ($PSDependAction -contains 'Resolve') { + if ($Credential -and $Source -notmatch '^https://') { + Write-Error "Resolve for [$Name] requires an HTTPS Source when Credential is supplied; got [$Source]" + return + } $packages = @(Find-NugetPackage -Name $Name -PackageSourceUrl $Source -Credential $Credential) $stable = @($packages | Where-Object { $_.Properties.IsPrerelease -ne 'true' }) $resolvedVersion = $null diff --git a/PSDepend/Private/Resolve-PSDependLock.ps1 b/PSDepend/Private/Resolve-PSDependLock.ps1 index 876b563..38ba35c 100644 --- a/PSDepend/Private/Resolve-PSDependLock.ps1 +++ b/PSDepend/Private/Resolve-PSDependLock.ps1 @@ -224,6 +224,35 @@ function Resolve-PSDependLock { } } + # A cyclic lock cannot be materialized into PSDepend's prerequisite DAG. + # Reject it here so Update-PSDependLock never writes a lock that consumers + # cannot sort. + $visitState = @{} + function Test-LockNodeCycle { + param([string]$Key, [string[]]$Path) + + if ($visitState[$Key] -eq 1) { + $cycleStart = [Array]::IndexOf($Path, $Key) + $cycle = @($Path[$cycleStart..($Path.Count - 1)]) + $Key + throw "Cannot lock dependency cycle [$($cycle -join ' -> ')]" + } + if ($visitState[$Key] -eq 2) { return } + + $visitState[$Key] = 1 + $nextPath = @($Path) + $Key + foreach ($childName in $nodes[$Key].Dependencies.Keys) { + $childKey = "$($nodes[$Key].DependencyType)::$childName" + if ($reachable.ContainsKey($childKey)) { + Test-LockNodeCycle -Key $childKey -Path $nextPath + } + } + $visitState[$Key] = 2 + } + + foreach ($key in ($reachable.Keys | Sort-Object)) { + Test-LockNodeCycle -Key $key -Path @() + } + $packages = [ordered]@{} foreach ($key in ($reachable.Keys | Sort-Object)) { $node = $nodes[$key] diff --git a/README.md b/README.md index 2497a75..2217981 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ PSDepend is extensible. To add a new dependency type, create a script in the [PS Your script must: - Include comment-based help describing how it uses `Dependency` metadata -- Accept a `PSDependAction` parameter with any actions it implements (`Install`, `Test`, `Import`, and optionally `Resolve`) +- Accept `Install`, `Test`, and `Import` in `PSDependAction`; optionally accept `Resolve` as a fourth action - For `Resolve`, query only and emit one `PSDepend.ResolvedDependency` with an exact `Version` and direct `Dependencies`; do not install See [Git.ps1](https://github.com/PowerShellOrg/PSDepend/blob/main/PSDepend/PSDependScripts/Git.ps1) and [PSGalleryModule.ps1](https://github.com/PowerShellOrg/PSDepend/blob/main/PSDepend/PSDependScripts/PSGalleryModule.ps1) for reference implementations. diff --git a/Tests/Nuget.Type.Tests.ps1 b/Tests/Nuget.Type.Tests.ps1 index 47e7a53..4e83b43 100644 --- a/Tests/Nuget.Type.Tests.ps1 +++ b/Tests/Nuget.Type.Tests.ps1 @@ -153,5 +153,19 @@ Describe 'Nuget script' { $result | Should -BeNullOrEmpty Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 } + + It 'Rejects an HTTP source when credentials would be transmitted' { + $credential = New-TestCredential -UserName 'feeduser' -Password 'feedpass' + $dep = New-PSDependFixture -DependencyName 'Newtonsoft.Json' -DependencyType 'Nuget' ` + -Source 'http://packages.example.test/api/v2/' -Credential $credential + + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err + $err[0].ToString() | Should -Match 'requires an HTTPS Source' + } + + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Find-NugetPackage -ModuleName PSDepend -Times 0 -Exactly + } } } diff --git a/Tests/PSDependLock.Tests.ps1 b/Tests/PSDependLock.Tests.ps1 index 3486775..e7aca52 100644 --- a/Tests/PSDependLock.Tests.ps1 +++ b/Tests/PSDependLock.Tests.ps1 @@ -129,7 +129,20 @@ Describe 'Update-PSDependLock' { { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | Should -Throw -ExpectedMessage '*FakeResolver::App*constraint*' } + + It 'Rejects a dependency cycle before writing the lock' { + $file = Initialize-LockProject -Name 'cycle' -Body @' +@{ + CycleA = @{ DependencyType = 'FakeResolver'; Version = '1.0.0' } } +'@ + + { Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath } | + Should -Throw -ExpectedMessage '*dependency cycle*CycleA*CycleB*CycleA*' + Test-Path (Join-Path (Split-Path $file) 'requirements.lock.json') | Should -BeFalse + } +} + Describe 'Import-PSDependLock validation' { diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index f33c0af..03742c8 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -188,5 +188,19 @@ Describe 'PSGalleryNuget script' { $result | Should -BeNullOrEmpty Should -Invoke -CommandName Invoke-ExternalCommand -ModuleName PSDepend -Times 0 } + + It 'Rejects an HTTP source when credentials would be transmitted' { + $credential = New-TestCredential -UserName 'feeduser' -Password 'feedpass' + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' ` + -Source 'http://packages.example.test/api/v2/' -Credential $credential + + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve -ErrorAction SilentlyContinue -ErrorVariable err + $err[0].ToString() | Should -Match 'requires an HTTPS Source' + } + + $result | Should -BeNullOrEmpty + Should -Invoke -CommandName Find-NugetPackage -ModuleName PSDepend -Times 0 -Exactly + } } } diff --git a/Tests/Shared/FakeResolver.ps1 b/Tests/Shared/FakeResolver.ps1 index 3246d34..801c8f7 100644 --- a/Tests/Shared/FakeResolver.ps1 +++ b/Tests/Shared/FakeResolver.ps1 @@ -45,6 +45,12 @@ $Graph = @{ '1.0.0' = @{} '2.0.0' = @{} } + CycleA = [ordered]@{ + '1.0.0' = @{ CycleB = '1.0.0' } + } + CycleB = [ordered]@{ + '1.0.0' = @{ CycleA = '1.0.0' } + } } $Name = if ($Dependency.Name) { $Dependency.Name } else { $Dependency.DependencyName } From d1eb4fc1c6e690486d2bd9a815549481f9be8be2 Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Sat, 3 Oct 2026 05:19:53 +0000 Subject: [PATCH 5/6] fix: address follow-up lock review --- CHANGELOG.md | 4 ++ PSDepend/PSDependScripts/Nuget.ps1 | 4 +- PSDepend/PSDependScripts/PSGalleryNuget.ps1 | 4 +- .../ConvertFrom-NugetDependencyString.ps1 | 17 +++++- PSDepend/Private/Find-NugetPackage.ps1 | 31 +++++++--- PSDepend/Private/Resolve-PSDependLock.ps1 | 42 ++++++------- ...onvertFrom-NugetDependencyString.Tests.ps1 | 11 +++- Tests/Find-NugetPackage.Tests.ps1 | 59 +++++++++++++++++++ Tests/Nuget.Type.Tests.ps1 | 11 +++- Tests/PSDependLock.Tests.ps1 | 32 +++++++++- Tests/PSGalleryNuget.Type.Tests.ps1 | 11 +++- Tests/Shared/FakeResolver.ps1 | 17 +++++- adr/0002-lock-resolution-model.md | 13 +++- 13 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 Tests/Find-NugetPackage.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 62628cb..73c0b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Invoke-DependencyScript -PSDependTypePath` is now passed through to the type/script lookup instead of always reading the module's `PSDependMap.psd1`. +- Lock resolution now exhausts paged NuGet v2 feeds, honors exact prerelease + requests, rejects ambiguous framework-specific dependency constraints, and + re-resolves whenever a combined constraint changes so the highest matching + version remains locked. ## [0.6.0] - 2026-09-30 diff --git a/PSDepend/PSDependScripts/Nuget.ps1 b/PSDepend/PSDependScripts/Nuget.ps1 index 0929d53..ad40a10 100644 --- a/PSDepend/PSDependScripts/Nuget.ps1 +++ b/PSDepend/PSDependScripts/Nuget.ps1 @@ -115,7 +115,9 @@ if ($PSDependAction -contains 'Resolve') { } } else { - $resolvedVersion = Resolve-VersionInRange -Candidate @($stable.Version) -Required $Version + $requestedRange = ConvertFrom-VersionRange -Version $Version + $candidates = if ($requestedRange.IsExact) { $packages } else { $stable } + $resolvedVersion = Resolve-VersionInRange -Candidate @($candidates.Version) -Required $Version } if (-not $resolvedVersion) { Write-Error "No version of [$DependencyName] at [$Source] satisfies [$Version]" diff --git a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 index 70de401..6b93f73 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -103,7 +103,9 @@ if ($PSDependAction -contains 'Resolve') { } } else { - $resolvedVersion = Resolve-VersionInRange -Candidate @($stable.Version) -Required $Version + $requestedRange = ConvertFrom-VersionRange -Version $Version + $candidates = if ($requestedRange.IsExact) { $packages } else { $stable } + $resolvedVersion = Resolve-VersionInRange -Candidate @($candidates.Version) -Required $Version } if (-not $resolvedVersion) { Write-Error "No version of [$Name] at [$Source] satisfies [$Version]" diff --git a/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 b/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 index 1c9bb5c..bdb106d 100644 --- a/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 +++ b/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 @@ -15,8 +15,9 @@ function ConvertFrom-NugetDependencyString { bracketed/parens -> kept, with internal whitespace removed ('[1.3.3, )' -> '[1.3.3,)') bare version 1.0.0 -> '[1.0.0,)' (NuGet bare means minimum inclusive; PSDepend bare means exact) - When the same id appears under several target frameworks, the first - occurrence wins. Null or empty input returns an empty hashtable. + When the same id appears under several target frameworks, identical ranges + are collapsed. Different ranges are rejected because PSDepend cannot know + which target framework the eventual NuGet installation will select. .PARAMETER Dependencies The raw Dependencies string from the feed's package metadata. @@ -40,6 +41,7 @@ function ConvertFrom-NugetDependencyString { ) $map = @{} + $frameworks = @{} if ([string]::IsNullOrWhiteSpace($Dependencies)) { return $map } @@ -47,7 +49,7 @@ function ConvertFrom-NugetDependencyString { foreach ($entry in $Dependencies -split '\|') { $parts = $entry -split ':', 3 $id = $parts[0].Trim() - if (-not $id -or $map.ContainsKey($id)) { + if (-not $id) { continue } @@ -59,7 +61,16 @@ function ConvertFrom-NugetDependencyString { $range = "[$range,)" } + $framework = if ($parts.Count -gt 2 -and $parts[2]) { $parts[2].Trim() } else { '' } + if ($map.ContainsKey($id)) { + if ($map[$id] -ne $range) { + throw "NuGet dependency [$id] has different constraints [$($map[$id])] for [$($frameworks[$id])] and [$range] for [$framework]; target-framework-specific dependency groups cannot be locked safely" + } + continue + } + $map[$id] = $range + $frameworks[$id] = $framework } $map diff --git a/PSDepend/Private/Find-NugetPackage.ps1 b/PSDepend/Private/Find-NugetPackage.ps1 index 19b29ef..6caa5f2 100644 --- a/PSDepend/Private/Find-NugetPackage.ps1 +++ b/PSDepend/Private/Find-NugetPackage.ps1 @@ -43,11 +43,28 @@ function Find-NugetPackage { $headers["Authentication"] = "Basic $basicAuthToken" } - Invoke-RestMethod $URI -Headers $headers | - Select-Object @{n = 'Name'; ex = { $_.title.('#text') } }, - @{n = 'Author'; ex = { $_.author.name } }, - @{n = 'Version'; ex = { $_.properties.NormalizedVersion } }, - @{n = 'Uri'; ex = { $_.Content.src } }, - @{n = 'Description'; ex = { $_.properties.Description } }, - @{n = 'Properties'; ex = { $_.properties } } + $entries = [System.Collections.Generic.List[object]]::new() + if (-not $IsLatest -and -not $PSBoundParameters.ContainsKey('Version')) { + $pageSize = 100 + $skip = 0 + do { + $page = @(Invoke-RestMethod "$URI&`$top=$pageSize&`$skip=$skip" -Headers $headers) + foreach ($entry in $page) { + $entries.Add($entry) + } + $skip += $page.Count + } while ($page.Count -gt 0) + } + else { + foreach ($entry in @(Invoke-RestMethod $URI -Headers $headers)) { + $entries.Add($entry) + } + } + + $entries | Select-Object @{n = 'Name'; ex = { $_.title.('#text') } }, + @{n = 'Author'; ex = { $_.author.name } }, + @{n = 'Version'; ex = { $_.properties.NormalizedVersion } }, + @{n = 'Uri'; ex = { $_.Content.src } }, + @{n = 'Description'; ex = { $_.properties.Description } }, + @{n = 'Properties'; ex = { $_.properties } } } diff --git a/PSDepend/Private/Resolve-PSDependLock.ps1 b/PSDepend/Private/Resolve-PSDependLock.ps1 index 38ba35c..ceeef27 100644 --- a/PSDepend/Private/Resolve-PSDependLock.ps1 +++ b/PSDepend/Private/Resolve-PSDependLock.ps1 @@ -86,13 +86,14 @@ function Resolve-PSDependLock { $entry.resolved = $key if (-not $nodes.ContainsKey($key)) { $nodes[$key] = @{ - Name = $name - DependencyType = $root.DependencyType - Version = $null - Dependencies = @{} - Template = $root - ContextHash = $contextHash - Constraints = @{} + Name = $name + DependencyType = $root.DependencyType + Version = $null + ResolvedConstraint = $null + Dependencies = @{} + Template = $root + ContextHash = $contextHash + Constraints = @{} } } elseif ($nodes[$key].ContextHash -ne $contextHash) { throw "Cannot lock [$key] from different resolution sources or parameters; declare a single source and parameter set for each DependencyType::Name" @@ -110,7 +111,7 @@ function Resolve-PSDependLock { } $key = $queue.Dequeue() $node = $nodes[$key] - $constraints = @($node.Constraints.Values) + $constraints = @($node.Constraints.Values | Sort-Object -Unique) # A single constraint is passed through verbatim so DependencyTypes with # their own range syntax (npm semver) still work; several are intersected. @@ -124,12 +125,11 @@ function Resolve-PSDependLock { } } + if ($node.Version -and $node.ResolvedConstraint -ceq $combined) { + continue + } if ($node.Version) { - $satisfied = if ($combined -eq 'latest') { $true } else { Test-VersionInRange -Version $node.Version -Required $combined } - if ($satisfied) { - continue - } - Write-Verbose "Re-resolving [$key]: version [$($node.Version)] no longer satisfies [$combined]" + Write-Verbose "Re-resolving [$key]: constraint changed from [$($node.ResolvedConstraint)] to [$combined]" } $stateKey = "$key`n$combined`n$($node.Version)" @@ -180,6 +180,7 @@ function Resolve-PSDependLock { } $node.Version = [string]$result.Version + $node.ResolvedConstraint = [string]$combined $node.Dependencies = @{} if ($result.Dependencies) { foreach ($childName in $result.Dependencies.Keys) { @@ -193,13 +194,14 @@ function Resolve-PSDependLock { $childContextHash = Get-PSDependResolutionContext -Dependency $template if (-not $nodes.ContainsKey($childKey)) { $nodes[$childKey] = @{ - Name = $childName - DependencyType = $node.DependencyType - Version = $null - Dependencies = @{} - Template = $template - ContextHash = $childContextHash - Constraints = @{} + Name = $childName + DependencyType = $node.DependencyType + Version = $null + ResolvedConstraint = $null + Dependencies = @{} + Template = $template + ContextHash = $childContextHash + Constraints = @{} } } elseif ($nodes[$childKey].ContextHash -ne $childContextHash) { throw "Cannot lock [$childKey] from different resolution sources or parameters; declare a single source and parameter set for each DependencyType::Name" diff --git a/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 b/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 index 2422c46..19b7b53 100644 --- a/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 +++ b/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 @@ -80,14 +80,21 @@ Describe 'ConvertFrom-NugetDependencyString' { } } - It 'Keeps the first occurrence when an id appears under several frameworks' { + It 'Keeps one constraint when duplicate framework groups agree' { InModuleScope PSDepend { - $r = ConvertFrom-NugetDependencyString -Dependencies 'Foo:1.0.0:net45|Foo:2.0.0:netstandard2.0' + $r = ConvertFrom-NugetDependencyString -Dependencies 'Foo:1.0.0:net45|Foo:1.0.0:netstandard2.0' $r.Count | Should -Be 1 $r['Foo'] | Should -Be '[1.0.0,)' } } + It 'Rejects different constraints for the same id across framework groups' { + InModuleScope PSDepend { + { ConvertFrom-NugetDependencyString -Dependencies 'Foo:1.0.0:net45|Foo:2.0.0:netstandard2.0' } | + Should -Throw -ExpectedMessage '*Foo*different constraints*net45*netstandard2.0*' + } + } + It 'Handles an entry with no framework segment' { InModuleScope PSDepend { $r = ConvertFrom-NugetDependencyString -Dependencies 'Foo:1.0.0' diff --git a/Tests/Find-NugetPackage.Tests.ps1 b/Tests/Find-NugetPackage.Tests.ps1 new file mode 100644 index 0000000..ccdf927 --- /dev/null +++ b/Tests/Find-NugetPackage.Tests.ps1 @@ -0,0 +1,59 @@ +#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 'Find-NugetPackage' { + It 'Reads every OData page when all package versions are requested' { + InModuleScope PSDepend { + function New-FeedEntry { + param([string]$Version) + + [PSCustomObject]@{ + title = [PSCustomObject]@{ '#text' = 'Example' } + author = [PSCustomObject]@{ name = 'Author' } + Content = [PSCustomObject]@{ src = "https://example.test/$Version" } + properties = [PSCustomObject]@{ + NormalizedVersion = $Version + Description = 'Example package' + } + } + } + + Mock Invoke-RestMethod { + if ($Uri -match '\$skip=100') { + return New-FeedEntry -Version '101.0.0' + } + if ($Uri -match '\$skip=101') { + return + } + 1..100 | ForEach-Object { New-FeedEntry -Version "$_.0.0" } + } + + $result = @(Find-NugetPackage -Name 'Example' -PackageSourceUrl 'https://example.test/api/v2/') + + $result.Count | Should -Be 101 + $result[-1].Version | Should -Be '101.0.0' + Should -Invoke Invoke-RestMethod -Times 3 -Exactly -ParameterFilter { + $Uri -match '\$top=100&\$skip=(0|100|101)$' + } + } + } + + It 'Uses one request for an exact version' { + InModuleScope PSDepend { + Mock Invoke-RestMethod { @() } + + $null = Find-NugetPackage -Name 'Example' -Version '1.2.3' + + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Uri -notmatch '\$top=' -and $Uri -notmatch '\$skip=' + } + } + } +} diff --git a/Tests/Nuget.Type.Tests.ps1 b/Tests/Nuget.Type.Tests.ps1 index 4e83b43..3cc3387 100644 --- a/Tests/Nuget.Type.Tests.ps1 +++ b/Tests/Nuget.Type.Tests.ps1 @@ -1,4 +1,4 @@ -# cspell:ignore Newtonsoft noplatform +# cspell:ignore feedpass feeduser Newtonsoft noplatform #requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { @@ -134,6 +134,15 @@ Describe 'Nuget script' { $result.Dependencies['System.Memory'] | Should -Be '[4.5.4,)' } + It 'Resolves an explicitly requested prerelease version' { + $dep = New-PSDependFixture -DependencyName 'Newtonsoft.Json' -DependencyType 'Nuget' -Version '2.9.0-beta1' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + + $result.Version | Should -Be '2.9.0-beta1' + } + It 'Uses the Name parameter override as the package id' { $dep = New-PSDependFixture -DependencyName 'Portable.BouncyCastle' -DependencyType 'Nuget' -Name 'BouncyCastle.Crypto' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { diff --git a/Tests/PSDependLock.Tests.ps1 b/Tests/PSDependLock.Tests.ps1 index e7aca52..3ed26a6 100644 --- a/Tests/PSDependLock.Tests.ps1 +++ b/Tests/PSDependLock.Tests.ps1 @@ -1,4 +1,4 @@ -# cspell:ignore installignore nomatch +# cspell:ignore installignore nomatch Npmish Restrictor #requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { @@ -130,6 +130,36 @@ Describe 'Update-PSDependLock' { Should -Throw -ExpectedMessage '*FakeResolver::App*constraint*' } + It 'Re-resolves to the highest version whenever a combined constraint changes' { + $file = Initialize-LockProject -Name 'broadened-constraint' -Body @' +@{ + ChangingParent = @{ DependencyType = 'FakeResolver'; Version = 'latest' } + Restrictor = @{ DependencyType = 'FakeResolver'; Version = '1.0.0' } +} +'@ + + $lockPath = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath -PassThru + $lock = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json + + $lock.packages.'FakeResolver::ChangingParent'.version | Should -Be '1.0.0' + $lock.packages.'FakeResolver::Lib'.version | Should -Be '2.0.0' + } + + It 'Passes repeated identical non-NuGet constraints through unchanged' { + $file = Initialize-LockProject -Name 'same-native-constraint' -Body @' +@{ + First = @{ DependencyType = 'FakeResolver'; Name = 'Npmish'; Version = '^1.2.0' } + Second = @{ DependencyType = 'FakeResolver'; Name = 'Npmish'; Version = '^1.2.0' } + Third = @{ DependencyType = 'FakeResolver'; Name = 'Npmish'; Version = '^1.2.0' } +} +'@ + + $lockPath = Update-PSDependLock -Path $file -PSDependTypePath $script:MapPath -PassThru + $lock = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json + + $lock.packages.'FakeResolver::Npmish'.version | Should -Be '1.5.0' + } + It 'Rejects a dependency cycle before writing the lock' { $file = Initialize-LockProject -Name 'cycle' -Body @' @{ diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index 03742c8..c850e4d 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -1,4 +1,4 @@ -# cspell:ignore noplatform psgnuget +# cspell:ignore feedpass feeduser noplatform psgnuget #requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { @@ -178,6 +178,15 @@ Describe 'PSGalleryNuget script' { $result.Dependencies['BuildHelpers'] | Should -Be 'latest' } + It 'Resolves an explicitly requested prerelease version' { + $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Version '2.9.0-beta1' + $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { + & $ScriptPath -Dependency $Dep -PSDependAction Resolve + } + + $result.Version | Should -Be '2.9.0-beta1' + } + It 'Errors with no output when nothing satisfies the range' { $dep = New-PSDependFixture -DependencyName 'PSDeploy' -DependencyType 'PSGalleryNuget' -Version '[5.0.0,)' $result = InModuleScope PSDepend -Parameters @{ Dep = $dep; ScriptPath = $script:ScriptPath } { diff --git a/Tests/Shared/FakeResolver.ps1 b/Tests/Shared/FakeResolver.ps1 index 801c8f7..976f8f9 100644 --- a/Tests/Shared/FakeResolver.ps1 +++ b/Tests/Shared/FakeResolver.ps1 @@ -1,3 +1,4 @@ +# cspell:ignore Npmish Restrictor <# .SYNOPSIS Test double for a DependencyScript that supports the Resolve action. @@ -51,6 +52,16 @@ $Graph = @{ CycleB = [ordered]@{ '1.0.0' = @{ CycleA = '1.0.0' } } + ChangingParent = [ordered]@{ + '1.0.0' = @{ Lib = '[1.0,)' } + '2.0.0' = @{ Lib = '[1.0,2.0)' } + } + Restrictor = [ordered]@{ + '1.0.0' = @{ ChangingParent = '[1.0,2.0)' } + } + Npmish = [ordered]@{ + '1.5.0' = @{} + } } $Name = if ($Dependency.Name) { $Dependency.Name } else { $Dependency.DependencyName } @@ -64,7 +75,11 @@ if ($PSDependAction -contains 'Resolve') { $candidates = @($Graph[$Name].Keys) $resolved = if ($Version -eq 'latest') { $candidates[-1] - } else { + } + elseif ($Name -eq 'Npmish' -and $Version -eq '^1.2.0') { + '1.5.0' + } + else { Resolve-VersionInRange -Candidate $candidates -Required $Version } if (-not $resolved) { diff --git a/adr/0002-lock-resolution-model.md b/adr/0002-lock-resolution-model.md index 53402bd..a38fefe 100644 --- a/adr/0002-lock-resolution-model.md +++ b/adr/0002-lock-resolution-model.md @@ -12,9 +12,10 @@ A Lock belongs to one DependencyFile and uses `DependencyType::Name` as package identity. It records one exact version for that identity across the file. Resolution selects the highest version satisfying the constraints currently known, intersects constraints from every parent, and re-resolves invalidated -children until reaching a fixed point. It does not backtrack to an older parent -version. Users must narrow a parent range when greedy selection hides a valid -older solution. +children until reaching a fixed point. A selected version is reused only while +its combined constraint is unchanged, preserving highest-version resolution if +a constraint broadens. It does not backtrack to an older parent version. Users +must narrow a parent range when greedy selection hides a valid older solution. Source and DependencyScript Parameters affect resolution. Root entries store a SHA-256 fingerprint of that context, without exposing its values, and a changed @@ -33,6 +34,12 @@ of direct dependency names to ranges. Npm ranges pass through as npm semver; multiple constraints use the shared NuGet intersection logic, so incompatible cross-parent npm ranges fail rather than being reinterpreted. +NuGet v2 version catalogues are read to exhaustion across OData pages. Exact +prerelease requests are allowed, while `latest` and range resolution exclude +prereleases. Because PSDepend has no target-framework input, identical NuGet +dependency constraints across framework groups are collapsed and differing +constraints are rejected rather than selecting a group arbitrarily. + Lock format version 1 validates object shape, package references, names, and exact versions before consumption. It does not record artifact URLs or content hashes. A committed Lock must therefore be reviewed like code. From a88973e52fbb4587b9ca9b536fc58c94ed0d1bfa Mon Sep 17 00:00:00 2001 From: Gilbert Sanchez Date: Sat, 3 Oct 2026 05:21:37 +0000 Subject: [PATCH 6/6] test: remove duplicate byte order marks --- Tests/Nuget.Type.Tests.ps1 | 2 +- Tests/PSGalleryNuget.Type.Tests.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/Nuget.Type.Tests.ps1 b/Tests/Nuget.Type.Tests.ps1 index 3cc3387..e22cd03 100644 --- a/Tests/Nuget.Type.Tests.ps1 +++ b/Tests/Nuget.Type.Tests.ps1 @@ -1,4 +1,4 @@ -# cspell:ignore feedpass feeduser Newtonsoft noplatform +# cspell:ignore feedpass feeduser Newtonsoft noplatform #requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll { diff --git a/Tests/PSGalleryNuget.Type.Tests.ps1 b/Tests/PSGalleryNuget.Type.Tests.ps1 index c850e4d..9c7594a 100644 --- a/Tests/PSGalleryNuget.Type.Tests.ps1 +++ b/Tests/PSGalleryNuget.Type.Tests.ps1 @@ -1,4 +1,4 @@ -# cspell:ignore feedpass feeduser noplatform psgnuget +# cspell:ignore feedpass feeduser noplatform psgnuget #requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } BeforeAll {