functions/_resolveExtensionVersionRanges.ps1
|
# <copyright file="_resolveExtensionVersionRanges.ps1" company="Endjin Limited"> # Copyright (c) Endjin Limited. All rights reserved. # </copyright> function _resolveExtensionVersionRanges { [CmdletBinding()] [OutputType([hashtable[]])] param ( [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable[]] $Extensions, # Where to install a version that satisfies every requirement, when no registered version does [Parameter(Mandatory)] [string] $TargetPath, [Parameter()] [AllowNull()] [System.Collections.IDictionary] $LockedExtensions ) # The version that an extension resolved to, when it is known (i.e. not a git branch or commit, or a local path) $getVersion = { param ($extension) $lockEntry = $extension.LockEntry $version = $null if ($lockEntry.source.type -eq 'psrepository' -and $lockEntry.resolved) { [semver]::TryParse($lockEntry.resolved, [ref]$version) | Out-Null } elseif ($lockEntry.source.type -eq 'git') { $ref = $lockEntry.Contains('resolvedRef') ? $lockEntry.resolvedRef : $lockEntry.requested $version = _getVersionFromGitTag -Ref "$ref" } $version } # The version that an extension requires: a range (from a minimum version), an exact version, or nothing that can # be checked (e.g. the latest version, or a git branch) $getRequirement = { param ($extension) $lockEntry = $extension.LockEntry if ($extension.ContainsKey('MinimumVersion')) { $range = _getExtensionVersionRange -MinimumVersion $extension.MinimumVersion $preRelease = [bool]$extension['PreRelease'] @{ Text = "$($range.Text)$($preRelease ? ', including pre-release versions' : '')"; Range = $range; PreRelease = $preRelease; Exact = $null } } elseif ($lockEntry.requested) { $exact = $null if ($lockEntry.source.type -eq 'psrepository') { [semver]::TryParse($lockEntry.requested, [ref]$exact) | Out-Null } elseif ($lockEntry.source.type -eq 'git') { $exact = _getVersionFromGitTag -Ref $lockEntry.requested } if ($exact) { @{ Text = $lockEntry.requested; Range = $null; Exact = $exact } } } } $satisfies = { param ($version, $requirement) if ($null -eq $version) { return $false } if ($requirement.Range) { # A pre-release version only satisfies a minimum version that includes pre-release versions return (_testExtensionVersionInRange -Version $version -Range $requirement.Range -PreRelease:$requirement.PreRelease) } return ($version -eq $requirement.Exact) } $describeRequirer = { param ($extension) $extension.LockEntry.direct ? 'The configuration' : "'$($extension.LockEntry.requiredBy -join "', '")'" } # The source of an extension, ignoring whether pre-release versions are included, as the lock file compares it $describeSource = { param ($extension) $source = $extension.LockEntry.source $repository = $source.type -eq 'git' ? (_getNormalizedGitRepositoryUrl -RepositoryUrl "$($source['repository'])") : "$($source['repository'])".TrimEnd('/').ToLowerInvariant() "$($source.type)|$repository|$($source['folderPath'])" } # Installs the newest version in every range, when they overlap and every extension requires the same source. Every # range ends at the next breaking change (e.g. the next major version), so overlapping ranges overlap in the range of # the highest minimum version. Returns the registered extension and its dependencies. $registerVersionInEveryRange = { param ($name, $requirements) if ($requirements | Where-Object { !$_.Requirement.Range }) { return } if (@($requirements | ForEach-Object { & $describeSource $_.Extension } | Select-Object -Unique).Count -gt 1) { return } $highest = $requirements | Sort-Object -Property { $_.Requirement.Range.Minimum } -Descending | Select-Object -First 1 $minimum = $highest.Requirement.Range.Minimum $range = _getExtensionVersionRange -MinimumVersion "$minimum" if ($requirements | Where-Object { $_.Requirement.Range.Maximum -ne $range.Maximum }) { return } $source = $highest.Extension.LockEntry.source $config = @{ Name = $name; MinimumVersion = "$minimum" } if ($source.type -eq 'git') { $config.GitRepository = $source.repository $config.GitRepositoryFolderPath = $source.folderPath } else { $config.PSRepository = $source.repository } # A pre-release version is only in every range when every extension includes pre-release versions if (!($requirements | Where-Object { !$_.Requirement.PreRelease })) { $config.PreRelease = $true } Write-Host "Installing a version of extension '$name' that satisfies every extension that requires it: $($range.Text)" -f Cyan Register-ExtensionAndDependencies -ExtensionConfig $config ` -TargetPath $TargetPath ` -RequiredBy ($highest.Extension.LockEntry.requiredBy | Select-Object -First 1) ` -LockedExtensions $LockedExtensions } # Whether an extension was installed, whose dependencies may also need to be reconciled $installedAny = $false # The occurrences that are not used once a version has been chosen, and the chosen occurrences, compared by reference $discarded = [System.Collections.Generic.HashSet[object]]::new([System.Collections.Generic.ReferenceEqualityComparer]::Instance) $chosen = [System.Collections.Generic.HashSet[object]]::new([System.Collections.Generic.ReferenceEqualityComparer]::Instance) # Discards every occurrence of an extension other than the first one, which is the chosen version. The chosen # occurrence records every extension that required the extension, so that the lock file still lists them. $discardOtherOccurrences = { param ($indices) $winner = $resolved[$indices[0]] $chosen.Add($winner) | Out-Null foreach ($index in ($indices | Select-Object -Skip 1)) { $other = $resolved[$index] if ([object]::ReferenceEquals($other, $winner)) { continue } $discarded.Add($other) | Out-Null if ($other.ContainsKey('LockEntry')) { $winner.LockEntry.requiredBy = [string[]]@(@($winner.LockEntry.requiredBy) + @($other.LockEntry.requiredBy) | Select-Object -Unique) $winner.LockEntry.direct = $winner.LockEntry.direct -or $other.LockEntry.direct } } } # Find every occurrence of each extension, in the order that they were registered $resolved = [System.Collections.Generic.List[hashtable]]::new($Extensions) $indicesByName = [ordered]@{} for ($i = 0; $i -lt $resolved.Count; $i++) { $name = $resolved[$i].Name if (!$indicesByName.Contains($name)) { $indicesByName[$name] = [System.Collections.Generic.List[int]]::new() } $indicesByName[$name].Add($i) } foreach ($name in $indicesByName.Keys) { $indices = $indicesByName[$name] $first = $resolved[$indices[0]] if ($indices.Count -lt 2 -or !$first.ContainsKey('LockEntry')) { continue } $occurrences = @($indices | ForEach-Object { $resolved[$_] } | Where-Object { $_.ContainsKey('LockEntry') }) $requirements = @(foreach ($occurrence in $occurrences) { $requirement = & $getRequirement $occurrence if ($requirement) { @{ Requirement = $requirement; Extension = $occurrence } } }) # Only minimum versions are reconciled; other differences are reported by 'Resolve-ExtensionDuplicates' if (!($requirements | Where-Object { $_.Requirement.Range })) { continue } # The configuration can choose a version that the extensions requiring it do not, which takes precedence. Its # occurrence is not first when an extension listed before it requires the same extension, as registration is # depth-first, so move it to the front. $directIndex = $indices | Where-Object { $resolved[$_].ContainsKey('LockEntry') -and $resolved[$_].LockEntry.direct } | Select-Object -First 1 if ($null -ne $directIndex -and $directIndex -ne $indices[0]) { $direct = $resolved[$directIndex] $resolved[$directIndex] = $first $resolved[$indices[0]] = $direct $first = $direct } # An occurrence that does not match the lock file was not installed, and the run fails when the lock file is # checked, which reports the reason. When the configuration chooses the version, the occurrences that it # overrides are not used, so they do not need to match (e.g. a minimum version that the locked version # chosen by the configuration is not in). if ($first['LockMismatch'] -or (!$first.LockEntry.direct -and @($indices | Where-Object { $resolved[$_]['LockMismatch'] }).Count -gt 0)) { continue } # Later steps use the first occurrence of each extension, so it must satisfy every requirement $firstVersion = & $getVersion $first $unsatisfied = @($requirements | Where-Object { !(& $satisfies $firstVersion $_.Requirement) }) if ($unsatisfied.Count -eq 0) { & $discardOtherOccurrences $indices continue } if ($first.LockEntry.direct) { Write-Warning ("Extension '$name'$($firstVersion ? " $firstVersion" : '') is used because it is in the configuration, but " + (($unsatisfied | ForEach-Object { "$(& $describeRequirer $_.Extension) requires $($_.Requirement.Text)" }) -join '; ')) & $discardOtherOccurrences $indices continue } # Otherwise, use the newest occurrence that satisfies every requirement. This happens when an installed copy # satisfied the first requirement, but is older than a later requirement needs. $best = $occurrences | Where-Object { $_.Enabled } | ForEach-Object { $occurrence = $_ $version = & $getVersion $occurrence if ($version -and !($requirements | Where-Object { !(& $satisfies $version $_.Requirement) })) { @{ Extension = $occurrence; Version = $version } } } | Sort-Object -Property { $_.Version } -Descending | Select-Object -First 1 # Otherwise, a version that was not registered may satisfy every requirement (e.g. when an installed copy # satisfies one minimum version, and a pre-release satisfies another that includes pre-release versions) $installError = $null if (!$best) { try { $registered = @(& $registerVersionInEveryRange $name $requirements) } catch { $installError = $_.Exception.Message $registered = @() } if ($registered.Count -gt 0 -and $registered[0].Enabled) { $version = & $getVersion $registered[0] if ($version -and !($requirements | Where-Object { !(& $satisfies $version $_.Requirement) })) { $installedAny = $true $indices.Add($resolved.Count) $resolved.AddRange([hashtable[]]$registered) $best = @{ Extension = $registered[0]; Version = $version } } } } if (!$best) { $details = $requirements | ForEach-Object { $version = & $getVersion $_.Extension " - $(& $describeRequirer $_.Extension) requires $($_.Requirement.Text)$($version ? " (resolved to $version)" : '')" } throw ("No version of extension '$name' satisfies every extension that requires it:`n" + ($details -join "`n") + "`n" + ($installError ? "A version in every range could not be installed: $installError`n" : '') + "They may need different major versions. To use one version for all of them, add '$name' to the configuration.") } Write-Verbose "Using extension '$name' $($best.Version), which satisfies every extension that requires it" $bestIndex = $indices | Where-Object { [object]::ReferenceEquals($resolved[$_], $best.Extension) } | Select-Object -First 1 $resolved[$bestIndex] = $first $resolved[$indices[0]] = $best.Extension & $discardOtherOccurrences $indices } # Also discard the dependencies that only the discarded occurrences registered. A registered dependency is the # same object as the entry in its parent's 'dependencies'. A dependency that was chosen for every extension that # requires it is kept. if ($discarded.Count -gt 0) { $registeredExtensions = [System.Collections.Generic.HashSet[object]]::new([object[]]$resolved, [System.Collections.Generic.ReferenceEqualityComparer]::Instance) $pending = [System.Collections.Generic.Queue[object]]::new([object[]]@($discarded)) while ($pending.Count -gt 0) { $occurrence = $pending.Dequeue() foreach ($dependency in @($occurrence['dependencies'])) { if ($null -ne $dependency -and $registeredExtensions.Contains($dependency) -and !$chosen.Contains($dependency) -and $discarded.Add($dependency)) { Write-Verbose "Not using extension '$($dependency.Name)', as only a version of '$($occurrence.Name)' that is not used requires it" $pending.Enqueue($dependency) } } } $resolved = [System.Collections.Generic.List[hashtable]]::new([hashtable[]]@($resolved | Where-Object { !$discarded.Contains($_) })) } # The dependencies of an installed extension are registered after it, so reconcile them too if ($installedAny) { return _resolveExtensionVersionRanges -Extensions $resolved.ToArray() -TargetPath $TargetPath -LockedExtensions $LockedExtensions } return $resolved.ToArray() } |