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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Make `askr update` solve direct dependency constraints alongside peer constraints, holding back incompatible candidates with an explicit JSON-plan reason.
- Preserve public peer dependency floors when the selected release already satisfies the supported range.

## [0.2.1] - 2026-08-23

### Added
Expand Down
72 changes: 54 additions & 18 deletions src/update/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface PlannerOptions {
}

interface VersionMetadata extends Record<string, unknown> {
dependencies?: Record<string, unknown>;
peerDependencies?: Record<string, unknown>;
peerDependenciesMeta?: Record<string, unknown>;
}
Expand Down Expand Up @@ -148,14 +149,29 @@ function planOne(
targetVersion: target,
occurrence: { ...withVersions, status: "manual", reason: blocker },
};
if (
occurrence.section === "peerDependencies" &&
semver.satisfies(selected, occurrence.currentSpecification, { includePrerelease: true })
Comment thread
smiggleworth marked this conversation as resolved.
)
return {
targetVersion: target,
occurrence: {
...withVersions,
status: "current",
reason: "the selected version remains supported by the declared peer range",
},
};
const declared = semver.minVersion(occurrence.currentSpecification)?.version ?? allowed;
if (!semver.gt(selected, declared)) {
return {
targetVersion: target,
occurrence: {
...withVersions,
status: "current",
reason: blocker ?? "the declared version is current",
reason:
selected === target
? "the declared version is current"
: `compatible version ${selected} selected below ${tag}@${target}`,
},
};
}
Expand Down Expand Up @@ -226,7 +242,11 @@ function solveWorkspace(
cliTag: string | undefined,
localVersions: ReadonlyMap<string, string>,
mode: "update" | "upgrade",
): { choices: Map<string, string>; blockers: Map<string, string> } {
): {
choices: Map<string, string>;
blockers: Map<string, string>;
constrained: Set<string>;
} {
const selectedNames = new Set(
selectedOccurrences.filter((entry) => entry.kind === "fetch").map((entry) => entry.package),
);
Expand All @@ -242,11 +262,12 @@ function solveWorkspace(
let candidates = current ? [current] : [];
if (selectedNames.has(name) && packument && current) {
const target = selectedTarget(packument, cliTag ?? tags[name] ?? "latest");
const minimum = semver.minVersion(occurrence.currentSpecification)?.version ?? current;
Comment thread
smiggleworth marked this conversation as resolved.
if (target)
candidates = publishedVersions(packument)
.filter(
(version) =>
semver.gte(version, current) &&
semver.gte(version, minimum) &&
semver.lte(version, target) &&
(mode === "upgrade" || !isBreakingChange(current, version)),
)
Expand Down Expand Up @@ -301,20 +322,40 @@ function solveWorkspace(
if (!semver.satisfies(peerVersion, requirement, { includePrerelease: true }))
return `${name}@${version} requires ${peer}@${requirement}`;
}
for (const [dependency, requirement] of Object.entries(meta.dependencies ?? {}).sort(
Comment thread
smiggleworth marked this conversation as resolved.
([a], [b]) => a.localeCompare(b),
)) {
if (typeof requirement !== "string" || !semver.validRange(requirement)) continue;
const providerChanged = states.get(name)?.current !== version;
const assignedDependency = choices.get(dependency);
const dependencyChanged =
assignedDependency !== undefined &&
states.get(dependency)?.current !== assignedDependency;
if (!providerChanged && !dependencyChanged) continue;
const dependencyVersion = assignedDependency ?? installed.get(dependency);
if (!dependencyVersion) continue;
if (!semver.satisfies(dependencyVersion, requirement, { includePrerelease: true }))
return `${name}@${version} requires ${dependency}@${requirement}`;
}
}
return null;
};

// Join only packages that can constrain one another. Independent packages are
// resolved immediately instead of inflating a whole-workspace Cartesian search.
const edges = new Map(variables.map(([name]) => [name, new Set<string>()]));
const constrained = new Set<string>();
for (const [name, state] of variables) {
for (const version of state.candidates) {
const peers = metadata(packuments.get(name)!, version)?.peerDependencies ?? {};
for (const peer of Object.keys(peers)) {
if (!edges.has(peer)) continue;
edges.get(name)!.add(peer);
edges.get(peer)!.add(name);
const meta = metadata(packuments.get(name)!, version);
for (const constraints of [meta?.dependencies, meta?.peerDependencies]) {
for (const dependency of Object.keys(constraints ?? {})) {
if (states.has(dependency)) constrained.add(name);
if (!edges.has(dependency)) continue;
constrained.add(dependency);
edges.get(name)!.add(dependency);
edges.get(dependency)!.add(name);
}
}
}
}
Expand Down Expand Up @@ -343,7 +384,6 @@ function solveWorkspace(
const baseChoices = new Map([...choices].filter(([name]) => !componentSet.has(name)));
const domains = new Map(component.map((name) => [name, states.get(name)!.candidates] as const));
let best: Map<string, string> | null = null;
let bestChanged = -1;
let bestVector: number[] = [];
let statesVisited = 0;
let exhausted = false;
Expand All @@ -359,11 +399,6 @@ function solveWorkspace(
}
const merged = new Map([...baseChoices, ...assigned]);
const remaining = component.filter((name) => !assigned.has(name));
const changed = component.filter(
(name) => assigned.has(name) && assigned.get(name) !== states.get(name)!.current,
).length;
if (changed + remaining.length < bestChanged) return;

const pruned = new Map<string, readonly string[]>();
for (const name of remaining) {
const viable = domains
Expand All @@ -389,9 +424,8 @@ function solveWorkspace(
value < (bestVector[index] ?? Number.POSITIVE_INFINITY) &&
vector.slice(0, index).every((prior, priorIndex) => prior === bestVector[priorIndex]),
);
if (changed > bestChanged || (changed === bestChanged && newer)) {
if (!best || newer) {
best = new Map(assigned);
bestChanged = changed;
bestVector = vector;
}
return;
Expand Down Expand Up @@ -439,7 +473,7 @@ function solveWorkspace(
validate(attempted) ?? "no jointly peer-compatible update advances this dependency",
);
}
return { choices, blockers };
return { choices, blockers, constrained };
}

function aggregateStatus(occurrences: PlannedOccurrence[]): UpdateStatus {
Expand Down Expand Up @@ -508,7 +542,9 @@ export function planUpdates(options: PlannerOptions): UpdatePlan {
const solution = workspaceSolutions.get(occurrence.workspace);
const blocker = solution?.blockers.get(packageName);
const chosen =
mode === "update" && !blocker ? undefined : solution?.choices.get(packageName);
mode === "update" && !solution?.constrained.has(packageName)
? undefined
: solution?.choices.get(packageName);
return planOne(
occurrence,
options.packuments.get(packageName),
Expand Down
128 changes: 128 additions & 0 deletions tests/update-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ function occurrence(specification: string, packageName = "fixture"): DependencyO
};
}

function peerOccurrence(specification: string, packageName: string): DependencyOccurrence {
return { ...occurrence(specification, packageName), section: "peerDependencies" };
}

function packument(target: string, versions: string[]): Packument {
return {
"dist-tags": { latest: target },
Expand Down Expand Up @@ -198,6 +202,130 @@ describe("update range planner", () => {
]);
});

test("should keep a public peer floor given the selected release remains supported", () => {
const plan = planUpdates({
occurrences: [peerOccurrence(">=0.2.0 <0.3.0", "@askrjs/askr")],
packuments: new Map([["@askrjs/askr", packument("0.2.2", ["0.2.0", "0.2.1", "0.2.2"])]]),
});

expect(plan.decisions[0].occurrences[0]).toMatchObject({
status: "current",
proposedSpecification: null,
reason: "the selected version remains supported by the declared peer range",
});
});

test("should select a compatible direct dependency set below latest when updating", () => {
const vitePlus = occurrence("0.2.8", "vite-plus");
const vitest = occurrence("^4.1.10", "vitest");
const coverage = occurrence("^4.1.10", "@vitest/coverage-v8");
const plan = planUpdates({
occurrences: [vitePlus, vitest, coverage],
contextOccurrences: [vitePlus, vitest, coverage],
packuments: new Map([
[
"vite-plus",
{
"dist-tags": { latest: "0.2.9" },
versions: {
"0.2.8": { version: "0.2.8", dependencies: { vitest: "4.1.10" } },
"0.2.9": { version: "0.2.9", dependencies: { vitest: "4.1.10" } },
},
},
],
["vitest", packument("4.1.11", ["4.1.10", "4.1.11"])],
["@vitest/coverage-v8", packument("4.1.11", ["4.1.10", "4.1.11"])],
]),
});

expect(
Object.fromEntries(
plan.decisions.map((decision) => [
decision.package,
decision.occurrences[0].selectedVersion,
]),
),
).toEqual({
"@vitest/coverage-v8": "4.1.11",
"vite-plus": "0.2.9",
vitest: "4.1.10",
});
expect(plan.decisions.find((decision) => decision.package === "vitest")?.reason).toContain(
"compatible version 4.1.10 selected below latest@4.1.11",
);
});

test("should keep compatible packages at their current targets when updating", () => {
const packageA = occurrence("^1.0.0", "package-a");
const packageB = occurrence("^2.0.0", "package-b");
const plan = planUpdates({
occurrences: [packageA, packageB],
contextOccurrences: [packageA, packageB],
packuments: new Map([
[
"package-a",
{
"dist-tags": { latest: "1.9.0" },
versions: {
"1.0.0": { version: "1.0.0", peerDependencies: { "package-b": "^2.0.0" } },
"1.5.0": { version: "1.5.0", peerDependencies: { "package-b": "^2.0.0" } },
"1.9.0": { version: "1.9.0", peerDependencies: { "package-b": "^2.0.0" } },
},
},
],
[
"package-b",
{
"dist-tags": { latest: "2.9.0" },
versions: {
"2.0.0": { version: "2.0.0", peerDependencies: { "package-a": "^1.0.0" } },
"2.5.0": { version: "2.5.0", peerDependencies: { "package-a": "^1.0.0" } },
"2.9.0": { version: "2.9.0", peerDependencies: { "package-a": "^1.0.0" } },
},
},
],
]),
});

expect(
Object.fromEntries(
plan.decisions.map((decision) => [
decision.package,
decision.occurrences[0].selectedVersion,
]),
),
).toEqual({ "package-a": "1.9.0", "package-b": "2.9.0" });
});

test("should ignore an unchanged dependency mismatch outside the updated component", () => {
const packageA = occurrence("1.0.0", "package-a");
const packageB = occurrence("2.0.0", "package-b");
const packageC = occurrence("^3.0.0", "package-c");
const plan = planUpdates({
occurrences: [packageC],
contextOccurrences: [packageA, packageB, packageC],
packuments: new Map([
[
"package-a",
{
"dist-tags": { latest: "1.0.0" },
versions: {
"1.0.0": { version: "1.0.0", dependencies: { "package-b": "^1.0.0" } },
},
},
],
["package-b", packument("2.0.0", ["2.0.0"])],
["package-c", packument("3.5.0", ["3.0.0", "3.5.0"])],
]),
});

expect(plan.decisions[0].occurrences[0]).toMatchObject({
selectedVersion: "3.5.0",
status: "safe",
proposedSpecification: "^3.5.0",
});
});

test("should choose an older compatible release below latest when upgrading", () => {
const app = occurrence("^1.0.0", "app");
const peer = occurrence("^1.0.0", "peer");
Expand Down