diff --git a/CHANGELOG.md b/CHANGELOG.md index 95713e0..73c0b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 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 + +- `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 ### 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..8020bba 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,8 +41,16 @@ 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 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 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 @@ -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..ac7aeb7 100644 --- a/PSDepend/PSDependScripts/Chocolatey.ps1 +++ b/PSDepend/PSDependScripts/Chocolatey.ps1 @@ -24,10 +24,12 @@ 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. Requires an HTTP(S) NuGet v2 feed URL and performs no installation. .EXAMPLE @{ @@ -76,7 +78,7 @@ param( [string]$ChocoInstallScriptUrl = 'https://community.chocolatey.org/install.ps1', - [ValidateSet('Test', 'Install')] + [ValidateSet('Test', 'Install', 'Resolve')] [string[]]$PSDependAction = @('Install') ) @@ -237,6 +239,55 @@ 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 + } + 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 + 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..5657a8b 100644 --- a/PSDepend/PSDependScripts/Npm.ps1 +++ b/PSDepend/PSDependScripts/Npm.ps1 @@ -7,9 +7,14 @@ Note: We require npm in your path. + 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', @@ -23,10 +28,12 @@ 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 npm for the highest version satisfying Version and report it. + NuGet range syntax is rejected. Performs no installation. .EXAMPLE @{ @@ -59,7 +66,7 @@ param ( [PSTypeName('PSDepend.Dependency')] [PSObject[]]$Dependency, - [ValidateSet('Test', 'Install')] + [ValidateSet('Test', 'Install', 'Resolve')] [string[]]$PSDependAction = @('Install'), [switch]$Force, [switch]$Global @@ -81,6 +88,32 @@ 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) { + 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..ad40a10 100644 --- a/PSDepend/PSDependScripts/Nuget.ps1 +++ b/PSDepend/PSDependScripts/Nuget.ps1 @@ -17,10 +17,12 @@ 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 +75,7 @@ param( [switch]$Force, - [ValidateSet('Test', 'Install')] + [ValidateSet('Test', 'Install', 'Resolve')] [string[]]$PSDependAction = @('Install'), [Alias('DLLName')] @@ -95,6 +97,42 @@ if (-not $Dependency.Source) { $Source = 'https://www.nuget.org/api/v2/' } +$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 + if ($Version -eq 'latest') { + foreach ($package in $stable) { + if (-not $resolvedVersion -or (Compare-Version -ReferenceVersion $package.Version -DifferenceVersion $resolvedVersion) -gt 0) { + $resolvedVersion = $package.Version + } + } + } + else { + $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]" + return + } + $resolved = $packages | Where-Object { $_.Version -eq $resolvedVersion } | Select-Object -First 1 + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $DependencyName + Version = $resolvedVersion + Dependencies = ConvertFrom-NugetDependencyString -Dependencies ([string]$resolved.Properties.Dependencies) + } + return +} + # We use target as a proxy for Scope $Target = $Dependency.Target if (-not $Dependency.Target) { @@ -102,8 +140,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..01deed8 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -40,11 +40,13 @@ 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. Honors AllowPrerelease and performs no installation. .EXAMPLE @{ @@ -117,7 +119,7 @@ param( [switch]$Import, - [ValidateSet('Test', 'Install', 'Import')] + [ValidateSet('Test', 'Install', 'Import', 'Resolve')] [string[]]$PSDependAction = @('Install') ) @@ -150,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]" @@ -220,6 +224,72 @@ 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'] + 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 } + [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..6b93f73 100644 --- a/PSDepend/PSDependScripts/PSGalleryNuget.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryNuget.ps1 @@ -23,11 +23,13 @@ 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 +65,7 @@ param( [switch]$Import, - [ValidateSet('Test', 'Install', 'Import')] + [ValidateSet('Test', 'Install', 'Import', 'Resolve')] [string[]]$PSDependAction = @('Install') ) # Extract data from Dependency @@ -83,6 +85,42 @@ if (-not $Dependency.Source) { $Source = 'https://www.powershellgallery.com/api/v2/' } +$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 + if ($Version -eq 'latest') { + foreach ($package in $stable) { + if (-not $resolvedVersion -or (Compare-Version -ReferenceVersion $package.Version -DifferenceVersion $resolvedVersion) -gt 0) { + $resolvedVersion = $package.Version + } + } + } + else { + $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]" + return + } + $resolved = $packages | Where-Object { $_.Version -eq $resolvedVersion } | Select-Object -First 1 + [PSCustomObject]@{ + PSTypeName = 'PSDepend.ResolvedDependency' + Name = $Name + Version = $resolvedVersion + Dependencies = ConvertFrom-NugetDependencyString -Dependencies ([string]$resolved.Properties.Dependencies) + } + return +} + # We use target as a proxy for Scope $Target = $Dependency.Target if (-not $Dependency.Target) { @@ -90,8 +128,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..0fb590a 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/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 new file mode 100644 index 0000000..bdb106d --- /dev/null +++ b/PSDepend/Private/ConvertFrom-NugetDependencyString.ps1 @@ -0,0 +1,77 @@ +# cspell:ignore Newtonsoft +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, 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. + + .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 = @{} + $frameworks = @{} + if ([string]::IsNullOrWhiteSpace($Dependencies)) { + return $map + } + + foreach ($entry in $Dependencies -split '\|') { + $parts = $entry -split ':', 3 + $id = $parts[0].Trim() + if (-not $id) { + continue + } + + $range = if ($parts.Count -gt 1) { $parts[1] -replace '\s', '' } else { '' } + if (-not $range) { + $range = 'latest' + } + elseif ($range -notmatch '[\[\](),]') { + $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/Export-PSDependLock.ps1 b/PSDepend/Private/Export-PSDependLock.ps1 new file mode 100644 index 0000000..952fe4a --- /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 + 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. + + .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..71c74b2 --- /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 normalized 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 --json -- $PackageName version + } else { + $json = npm view --json -- "$PackageName@$Version" version + } + + if ([string]::IsNullOrWhiteSpace(($json -join ''))) { + return + } + + [string[]]@(($json -join "`n") | ConvertFrom-Json) +} diff --git a/PSDepend/Private/Find-NugetPackage.ps1 b/PSDepend/Private/Find-NugetPackage.ps1 index 1e26a61..6caa5f2 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 = @{} @@ -40,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/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/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 new file mode 100644 index 0000000..84051ce --- /dev/null +++ b/PSDepend/Private/Import-PSDependLock.ps1 @@ -0,0 +1,114 @@ +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 + } + + + 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 + } catch { + throw "Lock file [$Path] is not valid JSON: $_" + } + $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)" + } + 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" + } + + 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/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..7fe8793 --- /dev/null +++ b/PSDepend/Private/Merge-PSDependLock.ps1 @@ -0,0 +1,205 @@ +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 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. + + .PARAMETER Lock + The lock object from Import-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, + + [Parameter(Mandatory)] + [System.Collections.IDictionary]$Lock, + + [Parameter(Mandatory)] + [string]$LockPath, + + [Parameter(Mandatory)] + [string]$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 = 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]" + ) + } + if ($entry.contextHash -ne $contextHash) { + $null = $problems.Add("[$($root.DependencyName)] resolution source or parameters changed") + } + } + 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) { + $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 + } + + + $synthesized = [ordered]@{} + $usedDependencyNames = @{} + foreach ($root in $Dependency) { + $usedDependencyNames[$root.DependencyName] = $true + } + + 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) + 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] + } + + # 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, + [string]$RootKey, + [string]$ContextName + ) + + if ($Key -eq $RootKey) { + return $Template.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 = (New-LockedDependencyName -Name $name -Version $package.version -ContextName $ContextName) + 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 + } + # 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, + [string]$RootKey, + [string]$ContextName + ) + + $names = foreach ($childName in $Package.dependencies.Keys) { + Resolve-LockedChild -Key "${DependencyType}::$childName" -Template $Template ` + -RootKey $RootKey -ContextName $ContextName + } + 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 -RootKey $entry.resolved -ContextName $root.DependencyName + if ($children) { + $dependencies = @($root.DependsOn) + $children + $root.DependsOn = @($dependencies | 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..ceeef27 --- /dev/null +++ b/PSDepend/Private/Resolve-PSDependLock.ps1 @@ -0,0 +1,276 @@ +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 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 + 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 = 1000 + $resolutionStates = @{} + + $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] + } + + $roots = [ordered]@{} + $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-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" + $entry.resolved = $key + if (-not $nodes.ContainsKey($key)) { + $nodes[$key] = @{ + 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" + } + $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 + (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] + $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. + 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 -and $node.ResolvedConstraint -ceq $combined) { + continue + } + if ($node.Version) { + Write-Verbose "Re-resolving [$key]: constraint changed from [$($node.ResolvedConstraint)] to [$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' + 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" + } + 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 + foreach ($childKey in @($nodes.Keys)) { + $null = $nodes[$childKey].Constraints.Remove("node:$key") + } + + $node.Version = [string]$result.Version + $node.ResolvedConstraint = [string]$combined + $node.Dependencies = @{} + if ($result.Dependencies) { + foreach ($childName in $result.Dependencies.Keys) { + $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 + 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" + } + $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") + } + } + + # 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] + $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/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 854c39c..1ef5d5a 100644 --- a/PSDepend/Public/Get-Dependency.ps1 +++ b/PSDepend/Public/Get-Dependency.ps1 @@ -121,11 +121,23 @@ function Get-Dependency { AnotherPrivatePackage = $morePrivateCredentials } + .PARAMETER IgnoreLock + Skip any .lock.json next to a DependencyFile and return Dependencies as declared. + + 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 #> @@ -144,7 +156,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 +439,17 @@ 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 (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 ` + -DependencyFile $DependencyFile + } 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..6cb1343 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 DependencyFile and use versions as declared. + + 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 @@ -91,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 @@ -156,7 +168,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 +209,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..5945b4a --- /dev/null +++ b/PSDepend/Public/Update-PSDependLock.ps1 @@ -0,0 +1,112 @@ +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 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. + + .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..f3906f8 100644 --- a/PSDepend/en-US/about_PSDepend.help.txt +++ b/PSDepend/en-US/about_PSDepend.help.txt @@ -105,6 +105,37 @@ 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 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 ================== PSDepend is somewhat extensible. To add a new dependency type: diff --git a/README.md b/README.md index ff6b949..2217981 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,27 @@ 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` 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. + +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. + ## Getting Help Each dependency type may handle standard properties differently and expose its own parameters. Use `Get-PSDependType` to see what is available: @@ -187,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 `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/Chocolatey.Type.Tests.ps1 b/Tests/Chocolatey.Type.Tests.ps1 index 6805d4e..58708ec 100644 --- a/Tests/Chocolatey.Type.Tests.ps1 +++ b/Tests/Chocolatey.Type.Tests.ps1 @@ -150,3 +150,118 @@ 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 '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 + } + 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/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/ConvertFrom-NugetDependencyString.Tests.ps1 b/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 new file mode 100644 index 0000000..19b7b53 --- /dev/null +++ b/Tests/ConvertFrom-NugetDependencyString.Tests.ps1 @@ -0,0 +1,105 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe '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 one constraint when duplicate framework groups agree' { + InModuleScope PSDepend { + $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' + $r['Foo'] | Should -Be '[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/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..5802cf4 100644 --- a/Tests/Npm.Type.Tests.ps1 +++ b/Tests/Npm.Type.Tests.ps1 @@ -61,4 +61,62 @@ Describe 'Npm script' { } $result | Should -Be $true } + + Context 'PSDependAction = Resolve' { + It 'Picks the highest version satisfying an npm semver range' { + InModuleScope PSDepend { + Mock Find-NodeModule { [string[]]@('0.9.0', '0.10.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.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.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') } + } + $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..e22cd03 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 { @@ -93,4 +93,88 @@ 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 = '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 = '' } } + ) + } + } + } + + 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 '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 } { + & $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 + } + + 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 new file mode 100644 index 0000000..3ed26a6 --- /dev/null +++ b/Tests/PSDependLock.Tests.ps1 @@ -0,0 +1,366 @@ +# cspell:ignore installignore nomatch Npmish Restrictor +#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' + } +} +'@ + + $script:ResolvableBody = @' +@{ + App = @{ + DependencyType = 'FakeResolver' + Version = '[1.0,2.0)' + Target = '$DependencyFolder/target' + } +} +'@ +} + +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 '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 + $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*requires*' + 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*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 @' +@{ + 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' { + + 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*' + } + } +} + +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 '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' + $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*' + } + + 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' { + + It 'Installs locked versions, children first' { + $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 + + $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 '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:ResolvableBody + $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..f13c310 100644 --- a/Tests/PSGalleryModule.Type.Tests.ps1 +++ b/Tests/PSGalleryModule.Type.Tests.ps1 @@ -260,4 +260,89 @@ 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 + Should -Invoke -CommandName Get-PackageProvider -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..9c7594a 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 { @@ -136,4 +136,80 @@ 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 = '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 = '' } } + ) + } + } + } + + 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 '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 } { + & $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 + } + + 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/PSResourceGet.Type.Tests.ps1 b/Tests/PSResourceGet.Type.Tests.ps1 index 904f149..6ba1273 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,95 @@ 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() 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) + $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) + } + if (-not $Range) { + return [PSCustomObject]@{ Name = $Name; VersionRange = $null } + } + $normalized = @{ '4.9.0' = '[4.9.0, )'; '(, )' = '(, )' }[$Range] + [PSCustomObject]@{ Name = $Name; VersionRange = $normalized } + } + + 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..976f8f9 --- /dev/null +++ b/Tests/Shared/FakeResolver.ps1 @@ -0,0 +1,105 @@ +# cspell:ignore Npmish Restrictor +<# + .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' = @{} + } + CycleA = [ordered]@{ + '1.0.0' = @{ CycleB = '1.0.0' } + } + 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 } +$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] + } + elseif ($Name -eq 'Npmish' -and $Version -eq '^1.2.0') { + '1.5.0' + } + 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/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..a38fefe --- /dev/null +++ b/adr/0002-lock-resolution-model.md @@ -0,0 +1,56 @@ +# 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. 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 +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. + +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. + +## 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 4354e7f..d65d113 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 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 +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-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 a2eff48..fad3abe 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 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 +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..1ba45d0 --- /dev/null +++ b/docs/en-US/Update-PSDependLock.md @@ -0,0 +1,213 @@ +--- +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 `Resolve` +(`PSGalleryModule`, `PSResourceGet`, `PSGalleryNuget`, `Nuget`, `Chocolatey`, `Npm`) is resolved to the +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 + +### 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)