Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 135 additions & 5 deletions src/plumbing/hydrate.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { assert, assertEquals, assertRejects } from "@std/assert"
import { describe, it } from "@std/testing/bdd"
import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert"
// pin: import-map bare `@std/testing/bdd` does not resolve under current Deno
import { describe, it } from "jsr:@std/testing@1.0.3/bdd"
import { PackageRequirement } from "../types.ts"
import * as semver from "../utils/semver.ts"
import hydrate from "./hydrate.ts"
import hydrate, { MULTI_VERSION_PROJECTS } from "./hydrate.ts"

function constraintSet(pkgs: PackageRequirement[], project: string) {
return new Set(
pkgs.filter(p => p.project === project).map(p => p.constraint.toString())
)
}

describe("hydrate()", () => {
it("hydrates.1", async function() {
Expand Down Expand Up @@ -99,8 +106,7 @@ describe("hydrate()", () => {
}
})

const unicodes = rv.pkgs.filter(x => x.project === 'unicode.org')
const constraints = new Set(unicodes.map(x => x.constraint.toString()))
const constraints = constraintSet(rv.pkgs, 'unicode.org')
assertEquals(constraints.size, 2)
assert(constraints.has("^71"))
assert(constraints.has("^73"))
Expand All @@ -126,4 +132,128 @@ describe("hydrate()", () => {

await assertRejects(() => rv)
})

it("hydrates.multi-version.allowlist", function() {
assert(MULTI_VERSION_PROJECTS.has('unicode.org'))
assert(MULTI_VERSION_PROJECTS.has('openssl.org'))
assert(MULTI_VERSION_PROJECTS.has('abseil.io'))
assert(!MULTI_VERSION_PROJECTS.has('nodejs.org'))
})

it("hydrates.openssl.org", async function() {
// python locks ^1.1; cryptography needs ^3 — must coexist
const pkgs = [
{ project: 'python.org', constraint: new semver.Range('*') },
{ project: 'cryptography.io', constraint: new semver.Range('*') }
]

const rv = await hydrate(pkgs, (pkg: PackageRequirement, _dry: boolean) => {
if (pkg.project === 'python.org') {
return Promise.resolve([
{ project: 'openssl.org', constraint: new semver.Range('^1.1') }
])
} else if (pkg.project === 'cryptography.io') {
return Promise.resolve([
{ project: 'openssl.org', constraint: new semver.Range('^3') }
])
} else {
return Promise.resolve([])
}
})

const constraints = constraintSet(rv.pkgs, 'openssl.org')
assertEquals(constraints.size, 2)
assert(constraints.has("^1.1"))
assert(constraints.has("^3"))
// the two lines remain disjoint
assertThrows(() =>
semver.intersect(new semver.Range("^1.1"), new semver.Range("^3"))
)
})

it("hydrates.abseil.io", async function() {
const pkgs = [
{ project: 'github.com/google/re2', constraint: new semver.Range('*') },
{ project: 'grpc.io', constraint: new semver.Range('*') }
]

const rv = await hydrate(pkgs, (pkg: PackageRequirement, _dry: boolean) => {
if (pkg.project === 'github.com/google/re2') {
return Promise.resolve([
{ project: 'abseil.io', constraint: new semver.Range('^20250127') }
])
} else if (pkg.project === 'grpc.io') {
return Promise.resolve([
{ project: 'abseil.io', constraint: new semver.Range('>=20250512') }
])
} else {
return Promise.resolve([])
}
})

const constraints = constraintSet(rv.pkgs, 'abseil.io')
assertEquals(constraints.size, 2)
assert(constraints.has("^20250127"))
assert(constraints.has(">=20250512"))
})

it("hydrates.multi-version.dry-input", async function() {
// explicit +openssl^1.1 +openssl^3
const pkgs = [
{ project: 'openssl.org', constraint: new semver.Range('^1.1') },
{ project: 'openssl.org', constraint: new semver.Range('^3') }
]

const rv = await hydrate(pkgs, () => Promise.resolve([]))

const constraints = constraintSet(rv.pkgs, 'openssl.org')
assertEquals(constraints.size, 2)
assert(constraints.has("^1.1"))
assert(constraints.has("^3"))
assertEquals(constraintSet(rv.dry, 'openssl.org').size, 2)
})

it("hydrates.multi-version.three-way", async function() {
// cryptography first so ^3 is the graph node; both ^1.1 merge into one additional
const pkgs = [
{ project: 'cryptography.io', constraint: new semver.Range('*') },
{ project: 'python.org', constraint: new semver.Range('*') },
{ project: 'curl.se', constraint: new semver.Range('*') }
]

const rv = await hydrate(pkgs, (pkg: PackageRequirement, _dry: boolean) => {
if (pkg.project === 'python.org' || pkg.project === 'curl.se') {
return Promise.resolve([
{ project: 'openssl.org', constraint: new semver.Range('^1.1') }
])
} else if (pkg.project === 'cryptography.io') {
return Promise.resolve([
{ project: 'openssl.org', constraint: new semver.Range('^3') }
])
} else {
return Promise.resolve([])
}
})

const constraints = constraintSet(rv.pkgs, 'openssl.org')
assertEquals(constraints.size, 2)
assert(constraints.has("^1.1"))
assert(constraints.has("^3"))
})

it("hydrates.multi-version.dry-condense-compatible", async function() {
const pkgs = [
{ project: 'openssl.org', constraint: new semver.Range('^1.1') },
{ project: 'openssl.org', constraint: new semver.Range('>=1.1.1') }
]

const rv = await hydrate(pkgs, () => Promise.resolve([]))

// compatible ranges collapse to a single openssl line (still on 1.x)
const openssles = rv.pkgs.filter(p => p.project === 'openssl.org')
assertEquals(openssles.length, 1)
assertThrows(() =>
semver.intersect(openssles[0].constraint, new semver.Range("^3"))
)
})
})
70 changes: 59 additions & 11 deletions src/plumbing/hydrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ const { isArray } = is_what
//FIXME actually we are not refining the constraints currently
//TODO we are not actually restricting subsequent asks, eg. deno^1 but then deno^1.2

/// Projects whose distinct version lines are parallel-installable (different
/// sonames / ICU majors / abseil LTS namespaces). When constraints cannot
/// intersect we keep multiple nodes instead of failing the graph.
///
/// - unicode.org: ICU major ABI (see pantry#4104, pkgx#899)
/// - openssl.org: libssl.so.1.1 vs libssl.so.3
/// - abseil.io: LTS inline-namespace + soversion (20250127 vs 20250512, …)
export const MULTI_VERSION_PROJECTS = new Set([
"unicode.org",
"openssl.org",
"abseil.io",
])

interface ReturnValue {
/// full list topologically sorted (ie dry + wet)
Expand Down Expand Up @@ -55,14 +67,34 @@ export default async function hydrate(
const initial_set = new Set(dry.map(x => x.project))
const stack: Node[] = []

const additional_unicodes: semver.Range[] = []
// extra constraints for multi-version projects that could not intersect
const additional: PackageRequirement[] = []

const pushAdditional = (pkg: PackageRequirement) => {
for (const existing of additional.filter(p => p.project === pkg.project)) {
try {
existing.constraint = semver.intersect(existing.constraint, pkg.constraint)
return
} catch {
// try next sibling line
}
}
additional.push(pkg)
}

// Starting the DFS loop for each package in the dry list
for (const pkg of dry) {
let new_node = graph[pkg.project]
if (new_node) {
// Intersect constraints for existing nodes
new_node.pkg.constraint = semver.intersect(new_node.pkg.constraint, pkg.constraint)
try {
new_node.pkg.constraint = semver.intersect(new_node.pkg.constraint, pkg.constraint)
} catch (e) {
if (MULTI_VERSION_PROJECTS.has(pkg.project)) {
pushAdditional(pkg)
continue
}
throw e
}
} else {
new_node = new Node(pkg)
graph[pkg.project] = new_node
Expand All @@ -86,11 +118,9 @@ export default async function hydrate(
// Intersect constraints
child_node.pkg.constraint = semver.intersect(child_node.pkg.constraint, dep.constraint)
} catch (e) {
if (dep.project == 'unicode.org') {
// we handle unicode.org for now to allow situations like:
// https://github.com/pkgxdev/pantry/issues/4104
// https://github.com/pkgxdev/pkgx/issues/899
additional_unicodes.push(dep.constraint)
if (MULTI_VERSION_PROJECTS.has(dep.project)) {
// keep both version lines; bottles/rpaths disambiguate at runtime
pushAdditional(dep)
Comment on lines 119 to +123
} else {
throw e
}
Expand All @@ -111,8 +141,7 @@ export default async function hydrate(
.sort((a, b) => b.count() - a.count())
.map(({pkg}) => pkg)

// see above explanation
pkgs.push(...additional_unicodes.map(constraint => ({ project: "unicode.org", constraint })))
pkgs.push(...additional)

//TODO strictly we need to record precisely the bootstrap version constraint
const bootstrap_required = new Set(pkgs.compact(({project}) => bootstrap.has(project) && project))
Expand All @@ -130,7 +159,26 @@ function condense(pkgs: PackageRequirement[]) {
for (const pkg of pkgs) {
const found = out.find(x => x.project === pkg.project)
if (found) {
found.constraint = semver.intersect(found.constraint, pkg.constraint)
try {
found.constraint = semver.intersect(found.constraint, pkg.constraint)
} catch (e) {
if (MULTI_VERSION_PROJECTS.has(pkg.project)) {
// merge into a later non-intersecting sibling if possible
let merged = false
for (const sibling of out.filter(p => p.project === pkg.project).slice(1)) {
try {
sibling.constraint = semver.intersect(sibling.constraint, pkg.constraint)
merged = true
break
} catch {
// try next
}
}
if (!merged) out.push(pkg)
} else {
throw e
}
}
} else {
out.push(pkg)
}
Expand Down
Loading