diff --git a/CHANGELOG.md b/CHANGELOG.md index bde5898..49caf71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/update/planner.ts b/src/update/planner.ts index 5a78d11..2e8cc01 100644 --- a/src/update/planner.ts +++ b/src/update/planner.ts @@ -27,6 +27,7 @@ interface PlannerOptions { } interface VersionMetadata extends Record { + dependencies?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record; } @@ -148,6 +149,18 @@ function planOne( targetVersion: target, occurrence: { ...withVersions, status: "manual", reason: blocker }, }; + if ( + occurrence.section === "peerDependencies" && + semver.satisfies(selected, occurrence.currentSpecification, { includePrerelease: true }) + ) + 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 { @@ -155,7 +168,10 @@ function planOne( 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}`, }, }; } @@ -226,7 +242,11 @@ function solveWorkspace( cliTag: string | undefined, localVersions: ReadonlyMap, mode: "update" | "upgrade", -): { choices: Map; blockers: Map } { +): { + choices: Map; + blockers: Map; + constrained: Set; +} { const selectedNames = new Set( selectedOccurrences.filter((entry) => entry.kind === "fetch").map((entry) => entry.package), ); @@ -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; if (target) candidates = publishedVersions(packument) .filter( (version) => - semver.gte(version, current) && + semver.gte(version, minimum) && semver.lte(version, target) && (mode === "upgrade" || !isBreakingChange(current, version)), ) @@ -301,6 +322,21 @@ 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( + ([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; }; @@ -308,13 +344,18 @@ function solveWorkspace( // 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()])); + const constrained = new Set(); 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); + } } } } @@ -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 | null = null; - let bestChanged = -1; let bestVector: number[] = []; let statesVisited = 0; let exhausted = false; @@ -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(); for (const name of remaining) { const viable = domains @@ -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; @@ -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 { @@ -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), diff --git a/tests/update-range.test.ts b/tests/update-range.test.ts index 22bba56..01fba95 100644 --- a/tests/update-range.test.ts +++ b/tests/update-range.test.ts @@ -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 }, @@ -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");