From 11b45f7a75b0c4bc0b72a9fc1fccd7c10a04109a Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 14 Apr 2026 17:54:01 +0200 Subject: [PATCH 1/8] feat: add version range support for package dependencies Support caret (^) and tilde (~) version range operators in mops.toml dependencies, similar to npm and Cargo. - `mops add` now defaults to caret range (e.g., core = "^1.2.3") - Ranges resolved to highest satisfying version during install - Lock file stores exact resolved versions for reproducibility - Backend validates ^/~ prefixed versions in published configs - New getPackageVersions API endpoint for client-side resolution - Resolver validates all range constraints are satisfied post-resolution Made-with: Cursor --- backend/main/main-canister.mo | 4 + backend/main/utils/semver.mo | 23 +- backend/main/utils/validateConfig.mo | 6 +- cli/api/getPackageVersions.ts | 6 + cli/cache.ts | 10 + cli/commands/add.ts | 11 +- cli/commands/available-updates.ts | 76 +++++-- cli/commands/install/install-mops-dep.ts | 23 ++ cli/commands/update.ts | 9 +- cli/declarations/main/main.did | 6 + cli/declarations/main/main.did.d.ts | 3 + cli/declarations/main/main.did.js | 2 + cli/resolve-packages.ts | 127 +++++++---- cli/semver.ts | 135 ++++++++++++ cli/tests/semver.test.ts | 202 ++++++++++++++++++ docs/docs/09-mops.toml.md | 26 ++- .../01-how-dependecy-resolution-works.md | 30 ++- docs/docs/cli/1-deps/01-mops-add.md | 16 +- 18 files changed, 635 insertions(+), 80 deletions(-) create mode 100644 cli/api/getPackageVersions.ts create mode 100644 cli/semver.ts create mode 100644 cli/tests/semver.test.ts diff --git a/backend/main/main-canister.mo b/backend/main/main-canister.mo index bf360bcc..978feeb5 100644 --- a/backend/main/main-canister.mo +++ b/backend/main/main-canister.mo @@ -267,6 +267,10 @@ actor class Main() = this { Result.fromOption(registry.getHighestVersion(name), "Package '" # name # "' not found"); }; + public query func getPackageVersions(name : PackageName) : async Result.Result<[PackageVersion], Err> { + Result.fromOption(registry.getPackageVersions(name), "Package '" # name # "' not found"); + }; + func _getHighestSemver(name : PackageName, currentVersion : PackageVersion, semverPart : SemverPart) : Result.Result { let packageId = PackageUtils.getPackageId(name, currentVersion); if (packageConfigs.get(packageId) == null) { diff --git a/backend/main/utils/semver.mo b/backend/main/utils/semver.mo index eb386ff1..99df9db1 100644 --- a/backend/main/utils/semver.mo +++ b/backend/main/utils/semver.mo @@ -67,13 +67,34 @@ module { compare(x, y) == #equal; }; + public func stripRangePrefix(ver : Version) : Version { + let chars = ver.chars(); + switch (chars.next()) { + case (?'^') Text.fromIter(chars); + case (?'~') Text.fromIter(chars); + case _ ver; + }; + }; + + public func isRange(ver : Version) : Bool { + switch (ver.chars().next()) { + case (?'^') true; + case (?'~') true; + case _ false; + }; + }; + + public func validateVersionOrRange(ver : Version) : Result.Result<(), Err> { + validate(stripRangePrefix(ver)); + }; + // TODO: support 1.2.3-pre.1 public func validate(ver : Version) : Result.Result<(), Err> { var dots = 0; var prevChar = '.'; var index = 0; var digitSeq = 0; - let wrongFormatErr = #err("invalid version: wrong version fromat '" # ver # "'. Expected version format is 'xx.xx.xx'"); + let wrongFormatErr = #err("invalid version: wrong version format '" # ver # "'. Expected version format is 'xx.xx.xx'"); for (char in ver.chars()) { let unexpectedCharErr = #err("invalid version: unexpected char '" # Char.toText(char) # "' in '" # ver # "' at index " # Nat.toText(index)); diff --git a/backend/main/utils/validateConfig.mo b/backend/main/utils/validateConfig.mo index b5746822..bb73935d 100644 --- a/backend/main/utils/validateConfig.mo +++ b/backend/main/utils/validateConfig.mo @@ -248,7 +248,7 @@ module { }; if (dep.repo.size() == 0) { - let versionValid = Semver.validate(dep.version); + let versionValid = Semver.validateVersionOrRange(dep.version); if (Result.isErr(versionValid)) { return versionValid; }; @@ -261,8 +261,8 @@ module { let (_name, aliasVersion) = PackageUtils.parseDepName(dep.name); if (aliasVersion.size() > 0) { - // check alias prefix - if (not Text.startsWith(dep.version, #text(aliasVersion))) { + let bareVersion = Semver.stripRangePrefix(dep.version); + if (not Text.startsWith(bareVersion, #text(aliasVersion))) { return #err("Dependency alias version must be a prefix of the dependency version\nName: " # dep.name # "\nAlias: " # aliasVersion # "\nVersion: " # dep.version); }; }; diff --git a/cli/api/getPackageVersions.ts b/cli/api/getPackageVersions.ts new file mode 100644 index 00000000..b650dadc --- /dev/null +++ b/cli/api/getPackageVersions.ts @@ -0,0 +1,6 @@ +import { mainActor } from "./actors.js"; + +export async function getPackageVersions(pkgName: string) { + let actor = await mainActor(); + return actor.getPackageVersions(pkgName); +} diff --git a/cli/cache.ts b/cli/cache.ts index b75b0f5c..457f4907 100644 --- a/cli/cache.ts +++ b/cli/cache.ts @@ -77,6 +77,16 @@ export let copyCache = (cacheName: string, dest: string) => { }); }; +export function findCachedVersions(name: string): string[] { + let packagesDir = path.join(getGlobalCacheDir(), "packages"); + if (!fs.existsSync(packagesDir)) return []; + let prefix = name + "@"; + return fs + .readdirSync(packagesDir) + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => entry.slice(prefix.length)); +} + export let cacheSize = async () => { let dir = path.join(getGlobalCacheDir()); fs.mkdirSync(dir, { recursive: true }); diff --git a/cli/commands/add.ts b/cli/commands/add.ts index 4b8165fc..131351bb 100644 --- a/cli/commands/add.ts +++ b/cli/commands/add.ts @@ -17,6 +17,7 @@ import { checkRequirements } from "../check-requirements.js"; import { syncLocalCache } from "./install/sync-local-cache.js"; import { notifyInstalls } from "../notify-installs.js"; import { resolvePackages } from "../resolve-packages.js"; +import { stripRangePrefix } from "../semver.js"; type AddOptions = { verbose?: boolean; @@ -90,7 +91,7 @@ export async function add( console.log(chalk.red("Error: ") + versionRes.err); return; } - ver = versionRes.ok; + ver = "^" + versionRes.ok; } pkgDetails = { @@ -108,9 +109,11 @@ export async function add( process.exit(1); } } else if (!pkgDetails.path) { - let res = await installMopsDep(pkgDetails.name, pkgDetails.version, { - verbose: verbose, - }); + let res = await installMopsDep( + pkgDetails.name, + stripRangePrefix(pkgDetails.version), + { verbose }, + ); if (res === false) { return; } diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index d4a8ae9a..0575de85 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -1,9 +1,16 @@ import process from "node:process"; import chalk from "chalk"; import { mainActor } from "../api/actors.js"; +import { getPackageVersions } from "../api/getPackageVersions.js"; import { Config } from "../types.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; import { SemverPart } from "../declarations/main/main.did.js"; +import { + isRange, + parseRange, + highestSatisfying, + stripRangePrefix, +} from "../semver.js"; // [pkg, oldVersion, newVersion] export async function getAvailableUpdates( @@ -35,28 +42,61 @@ export async function getAvailableUpdates( return ""; }; - let actor = await mainActor(); - let res = await actor.getHighestSemverBatch( - depsToUpdate.map((dep) => { - let semverPart: SemverPart = { major: null }; + // Split into ranged and non-ranged deps + let rangedDeps = depsToUpdate.filter((dep) => isRange(dep.version || "")); + let exactDeps = depsToUpdate.filter((dep) => !isRange(dep.version || "")); + + let results: Array<[string, string]> = []; + + // Resolve ranged deps using getPackageVersions + local filtering + let rangedResults = await Promise.all( + rangedDeps.map(async (dep) => { let name = getDepName(dep.name); - let pinnedVersion = getDepPinnedVersion(dep.name); - if (pinnedVersion) { - semverPart = - pinnedVersion.split(".").length === 1 - ? { minor: null } - : { patch: null }; - } - return [name, dep.version || "", semverPart]; + let range = parseRange(dep.version || ""); + let versionsRes = await getPackageVersions(name); + if ("err" in versionsRes) return null; + let highest = highestSatisfying(versionsRes.ok, range); + return highest ? ([name, highest] as [string, string]) : null; }), ); + for (let r of rangedResults) { + if (r) results.push(r); + } + + // Resolve exact deps using existing getHighestSemverBatch + if (exactDeps.length > 0) { + let actor = await mainActor(); + let res = await actor.getHighestSemverBatch( + exactDeps.map((dep) => { + let semverPart: SemverPart = { major: null }; + let name = getDepName(dep.name); + let pinnedVersion = getDepPinnedVersion(dep.name); + if (pinnedVersion) { + semverPart = + pinnedVersion.split(".").length === 1 + ? { minor: null } + : { patch: null }; + } + return [name, stripRangePrefix(dep.version || ""), semverPart]; + }), + ); + + if ("err" in res) { + console.log(chalk.red("Error:"), res.err); + process.exit(1); + } - if ("err" in res) { - console.log(chalk.red("Error:"), res.err); - process.exit(1); + results.push(...res.ok); } - return res.ok - .filter((dep) => dep[1] !== getCurrentVersion(dep[0], dep[1])) - .map((dep) => [dep[0], getCurrentVersion(dep[0], dep[1]), dep[1]]); + return results + .filter((dep) => { + let current = getCurrentVersion(dep[0], dep[1]); + return stripRangePrefix(current) !== dep[1]; + }) + .map((dep) => [ + dep[0], + getCurrentVersion(dep[0], dep[1]), + dep[1], + ]); } diff --git a/cli/commands/install/install-mops-dep.ts b/cli/commands/install/install-mops-dep.ts index 53b71431..2db9b53c 100644 --- a/cli/commands/install/install-mops-dep.ts +++ b/cli/commands/install/install-mops-dep.ts @@ -7,6 +7,7 @@ import chalk from "chalk"; import { deleteSync } from "del"; import { checkConfigFile, progressBar, readConfig } from "../../mops.js"; import { getHighestVersion } from "../../api/getHighestVersion.js"; +import { getPackageVersions } from "../../api/getPackageVersions.js"; import { storageActor } from "../../api/actors.js"; import { parallel } from "../../parallel.js"; import { @@ -20,6 +21,12 @@ import { } from "../../api/downloadPackageFiles.js"; import { installDeps } from "./install-deps.js"; import { getDepName } from "../../helpers/get-dep-name.js"; +import { + isRange, + parseRange, + highestSatisfying, + formatRange, +} from "../../semver.js"; type InstallMopsDepOptions = { verbose?: boolean; @@ -67,6 +74,22 @@ export async function installMopsDep( return false; } version = versionRes.ok; + } else if (isRange(version)) { + let range = parseRange(version); + let versionsRes = await getPackageVersions(depName); + if ("err" in versionsRes) { + console.log(chalk.red("Error: ") + versionsRes.err); + return false; + } + let resolved = highestSatisfying(versionsRes.ok, range); + if (!resolved) { + console.log( + chalk.red("Error: ") + + `No version of "${depName}" satisfies ${formatRange(range)}`, + ); + return false; + } + version = resolved; } let cacheName = getMopsDepCacheName(depName, version); diff --git a/cli/commands/update.ts b/cli/commands/update.ts index 86620874..13db78e4 100644 --- a/cli/commands/update.ts +++ b/cli/commands/update.ts @@ -9,6 +9,7 @@ import { add } from "./add.js"; import { getAvailableUpdates } from "./available-updates.js"; import { checkIntegrity } from "../integrity.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; +import { isRange, stripRangePrefix } from "../semver.js"; type UpdateOptions = { verbose?: boolean; @@ -73,11 +74,12 @@ export async function update(pkg?: string, { lock }: UpdateOptions = {}) { let allDeps = [...Object.keys(config.dependencies || {}), ...devDeps]; let dev = false; + let bareOld = stripRangePrefix(dep[1]); for (let d of devDeps) { let pinnedVersion = getDepPinnedVersion(d); if ( getDepName(d) === dep[0] && - (!pinnedVersion || dep[1].startsWith(pinnedVersion)) + (!pinnedVersion || bareOld.startsWith(pinnedVersion)) ) { dev = true; break; @@ -89,11 +91,12 @@ export async function update(pkg?: string, { lock }: UpdateOptions = {}) { let pinnedVersion = getDepPinnedVersion(d); return ( getDepName(d) === dep[0] && - (!pinnedVersion || dep[1].startsWith(pinnedVersion)) + (!pinnedVersion || bareOld.startsWith(pinnedVersion)) ); }) || dep[0]; - await add(`${dep[0]}@${dep[2]}`, { dev, lock }, asName); + let rangePrefix = isRange(dep[1]) ? dep[1][0] : ""; + await add(`${dep[0]}@${rangePrefix}${dep[2]}`, { dev, lock }, asName); } } diff --git a/cli/declarations/main/main.did b/cli/declarations/main/main.did index 12b6e94c..4f9d4d53 100644 --- a/cli/declarations/main/main.did +++ b/cli/declarations/main/main.did @@ -60,6 +60,11 @@ type Script = name: text; value: text; }; +type Result_9 = + variant { + err: Err; + ok: vec PackageVersion; + }; type Result_8 = variant { err: Err; @@ -311,6 +316,7 @@ type Main = SemverPart; }) -> (Result_6) query; getHighestVersion: (name: PackageName) -> (Result_5) query; + getPackageVersions: (name: PackageName) -> (Result_9) query; getMostDownloadedPackages: () -> (vec PackageSummary) query; getMostDownloadedPackagesIn7Days: () -> (vec PackageSummary) query; getNewPackages: () -> (vec PackageSummary) query; diff --git a/cli/declarations/main/main.did.d.ts b/cli/declarations/main/main.did.d.ts index b83117c6..b7aab2c9 100644 --- a/cli/declarations/main/main.did.d.ts +++ b/cli/declarations/main/main.did.d.ts @@ -77,6 +77,7 @@ export interface Main { Result_6 >, 'getHighestVersion' : ActorMethod<[PackageName], Result_5>, + 'getPackageVersions' : ActorMethod<[PackageName], Result_9>, 'getMostDownloadedPackages' : ActorMethod<[], Array>, 'getMostDownloadedPackagesIn7Days' : ActorMethod<[], Array>, 'getNewPackages' : ActorMethod<[], Array>, @@ -298,6 +299,8 @@ export type Result_7 = { 'ok' : Array } | { 'err' : Err }; export type Result_8 = { 'ok' : Array<[FileId, Uint8Array | number[]]> } | { 'err' : Err }; +export type Result_9 = { 'ok' : Array } | + { 'err' : Err }; export interface Script { 'value' : string, 'name' : string } export type SemverPart = { 'major' : null } | { 'minor' : null } | diff --git a/cli/declarations/main/main.did.js b/cli/declarations/main/main.did.js index 3acbbb06..df9416a5 100644 --- a/cli/declarations/main/main.did.js +++ b/cli/declarations/main/main.did.js @@ -29,6 +29,7 @@ export const idlFactory = ({ IDL }) => { 'err' : Err, }); const Result_5 = IDL.Variant({ 'ok' : PackageVersion, 'err' : Err }); + const Result_9 = IDL.Variant({ 'ok' : IDL.Vec(PackageVersion), 'err' : Err }); const User = IDL.Record({ 'id' : IDL.Principal, 'emailVerified' : IDL.Bool, @@ -308,6 +309,7 @@ export const idlFactory = ({ IDL }) => { ['query'], ), 'getHighestVersion' : IDL.Func([PackageName], [Result_5], ['query']), + 'getPackageVersions' : IDL.Func([PackageName], [Result_9], ['query']), 'getMostDownloadedPackages' : IDL.Func( [], [IDL.Vec(PackageSummary)], diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index 1885919c..f4257801 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -10,9 +10,49 @@ import { } from "./mops.js"; import { VesselConfig, readVesselConfig } from "./vessel.js"; import { Config, Dependency } from "./types.js"; -import { getDepCacheDir, getDepCacheName } from "./cache.js"; +import { + findCachedVersions, + getDepCacheDir, + getDepCacheName, +} from "./cache.js"; import { getPackageId } from "./helpers/get-package-id.js"; import { checkLockFileLight, readLockFile } from "./integrity.js"; +import { + compareVersions, + highestSatisfying, + isRange, + parseRange, + satisfies, + stripRangePrefix, +} from "./semver.js"; + +type VersionConstraint = { + isMopsPackage: boolean; + version: string; + dependencyOf: string; +}; + +function resolveRangeFromCache( + name: string, + version: string, + cache: Map, +): string { + let key = `${name}@${version}`; + let cached = cache.get(key); + if (cached !== undefined) return cached; + + let bareVersion = stripRangePrefix(version); + if (!isRange(version)) { + cache.set(key, bareVersion); + return bareVersion; + } + + let installed = findCachedVersions(name); + let best = highestSatisfying(installed, parseRange(version)); + let resolved = best || bareVersion; + cache.set(key, resolved); + return resolved; +} export async function resolvePackages({ conflicts = "ignore" as "warning" | "error" | "ignore", @@ -31,37 +71,8 @@ export async function resolvePackages({ let rootDir = getRootDir(); let packages: Record = {}; - let versions: Record< - string, - Array<{ - isMopsPackage: boolean; - version: string; - dependencyOf: string; - }> - > = {}; - - let compareVersions = (a: string = "0.0.0", b: string = "0.0.0") => { - let ap = a.split(".").map((x: string) => parseInt(x) || 0) as [ - number, - number, - number, - ]; - let bp = b.split(".").map((x: string) => parseInt(x) || 0) as [ - number, - number, - number, - ]; - if (ap[0] - bp[0]) { - return Math.sign(ap[0] - bp[0]); - } - if (ap[0] === bp[0] && ap[1] - bp[1]) { - return Math.sign(ap[1] - bp[1]); - } - if (ap[0] === bp[0] && ap[1] === bp[1] && ap[2] - bp[2]) { - return Math.sign(ap[2] - bp[2]); - } - return 0; - }; + let versions: Record> = {}; + let rangeCache = new Map(); const gitVerRegex = new RegExp(/v(\d{1,2}\.\d{1,2}\.\d{1,2})(-.*)?$/); @@ -93,7 +104,14 @@ export async function resolvePackages({ for (const pkgDetails of allDeps) { const { name, repo, version } = pkgDetails; - // take root dep version or bigger one + // For version comparison, use resolved versions (not range floors) + let resolvedCurrent = version + ? resolveRangeFromCache(name, version, rangeCache) + : ""; + let resolvedExisting = packages[name]?.version + ? resolveRangeFromCache(name, packages[name]!.version || "", rangeCache) + : ""; + if ( isRoot || !packages[name] || @@ -101,7 +119,7 @@ export async function resolvePackages({ ((repo && packages[name]?.repo && compareGitVersions(packages[name]?.repo || "", repo) === -1) || - compareVersions(packages[name]?.version, version) === -1)) + compareVersions(resolvedExisting, resolvedCurrent) === -1)) ) { let temp = { ...pkgDetails, @@ -109,7 +127,6 @@ export async function resolvePackages({ }; packages[name] = temp; - // normalize path relative to the root config dir if (pkgDetails.path) { temp.path = path.relative( rootDir, @@ -121,7 +138,6 @@ export async function resolvePackages({ let nestedConfig; let localNestedDir = ""; - // read nested config if (repo) { let cacheDir = getDepCacheName(name, repo); nestedConfig = @@ -137,13 +153,13 @@ export async function resolvePackages({ nestedConfig = readConfig(mopsToml); } } else if (version) { - let cacheDir = getDepCacheName(name, version); + let resolved = resolveRangeFromCache(name, version, rangeCache); + let cacheDir = getDepCacheName(name, resolved); nestedConfig = readConfig( path.join(getDepCacheDir(cacheDir), "mops.toml"), ); } - // collect nested deps if (nestedConfig) { await collectDeps(nestedConfig, localNestedDir, false); } @@ -180,13 +196,14 @@ export async function resolvePackages({ let config = readConfig(); await collectDeps(config, rootDir, true); - // show conflicts let hasConflicts = false; if (conflicts !== "ignore") { for (let [dep, vers] of Object.entries(versions)) { + let mopsVers = vers.filter((x) => x.isMopsPackage); + let majors = new Set( - vers.filter((x) => x.isMopsPackage).map((x) => x.version.split(".")[0]), + mopsVers.map((x) => stripRangePrefix(x.version).split(".")[0]), ); if (majors.size > 1) { console.error( @@ -196,14 +213,40 @@ export async function resolvePackages({ ); for (let { version, dependencyOf } of [...vers].reverse()) { + let bare = stripRangePrefix(version); console.error( chalk.reset(" ") + - `${dep} ${chalk.bold.red(version.split(".")[0])}.${version.split(".").slice(1).join(".")} is dependency of ${chalk.bold(dependencyOf)}`, + `${dep} ${chalk.bold.red(bare.split(".")[0])}.${bare.split(".").slice(1).join(".")} is dependency of ${chalk.bold(dependencyOf)}`, ); } hasConflicts = true; } + + // Check range constraint satisfaction + let resolved = packages[dep]; + if (resolved?.version) { + let resolvedExact = resolveRangeFromCache( + dep, + resolved.version, + rangeCache, + ); + for (let constraint of mopsVers) { + if (isRange(constraint.version)) { + let range = parseRange(constraint.version); + if (!satisfies(resolvedExact, range)) { + console.error( + chalk.reset("") + + chalk.redBright( + conflicts === "error" ? "Error!" : "Warning!", + ), + `Resolved version ${dep}@${resolvedExact} does not satisfy constraint "${constraint.version}" required by ${chalk.bold(constraint.dependencyOf)}`, + ); + hasConflicts = true; + } + } + } + } } } @@ -222,7 +265,7 @@ export async function resolvePackages({ } else if (pkg.repo) { version = pkg.repo; } else if (pkg.version) { - version = pkg.version; + version = resolveRangeFromCache(name, pkg.version, rangeCache); } else { return [name, ""]; } diff --git a/cli/semver.ts b/cli/semver.ts new file mode 100644 index 00000000..6c200ef9 --- /dev/null +++ b/cli/semver.ts @@ -0,0 +1,135 @@ +export type RangeType = "exact" | "caret" | "tilde"; + +export type VersionRange = { + type: RangeType; + major: number; + minor: number; + patch: number; +}; + +export type ParsedVersion = [number, number, number]; + +export function parseVersion(ver: string): ParsedVersion { + let parts = ver.split(".").map((x) => parseInt(x) || 0); + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; +} + +export function compareVersions( + a: string = "0.0.0", + b: string = "0.0.0", +): number { + let [a0, a1, a2] = parseVersion(a); + let [b0, b1, b2] = parseVersion(b); + return Math.sign(a0 - b0) || Math.sign(a1 - b1) || Math.sign(a2 - b2); +} + +export function parseRange(spec: string): VersionRange { + let type: RangeType = "exact"; + let versionStr = spec; + + if (spec.startsWith("^")) { + type = "caret"; + versionStr = spec.slice(1); + } else if (spec.startsWith("~")) { + type = "tilde"; + versionStr = spec.slice(1); + } + + let [major, minor, patch] = parseVersion(versionStr); + return { type, major, minor, patch }; +} + +export function isRange(spec: string): boolean { + return spec.startsWith("^") || spec.startsWith("~"); +} + +export function stripRangePrefix(spec: string): string { + if (spec.startsWith("^") || spec.startsWith("~")) { + return spec.slice(1); + } + return spec; +} + +/** + * Compute the exclusive upper bound for a range. + * + * Caret (^) — leftmost non-zero component is the compatibility boundary: + * ^1.2.3 → <2.0.0 + * ^0.2.3 → <0.3.0 + * ^0.0.3 → <0.0.4 + * + * Tilde (~) — next minor is the boundary: + * ~1.2.3 → <1.3.0 + * ~0.2.3 → <0.3.0 + */ +export function upperBound(range: VersionRange): ParsedVersion { + let { type, major, minor, patch } = range; + + if (type === "exact") { + return [major, minor, patch + 1]; + } + + if (type === "tilde") { + return [major, minor + 1, 0]; + } + + // caret: leftmost non-zero component is the boundary + if (major !== 0) { + return [major + 1, 0, 0]; + } + if (minor !== 0) { + return [0, minor + 1, 0]; + } + return [0, 0, patch + 1]; +} + +export function lowerBound(range: VersionRange): ParsedVersion { + return [range.major, range.minor, range.patch]; +} + +function versionGte(v: ParsedVersion, bound: ParsedVersion): boolean { + return compareVersions(v.join("."), bound.join(".")) >= 0; +} + +function versionLt(v: ParsedVersion, bound: ParsedVersion): boolean { + return compareVersions(v.join("."), bound.join(".")) < 0; +} + +export function satisfies(version: string, range: VersionRange): boolean { + let v = parseVersion(version); + let lo = lowerBound(range); + let hi = upperBound(range); + return versionGte(v, lo) && versionLt(v, hi); +} + +/** + * Find the highest version from a list that satisfies the given range. + * Returns undefined if no version satisfies. + */ +export function highestSatisfying( + versions: string[], + range: VersionRange, +): string | undefined { + let best: string | undefined; + for (let v of versions) { + if (satisfies(v, range) && (!best || compareVersions(v, best) > 0)) { + best = v; + } + } + return best; +} + +/** + * Format a range back to its string representation. + */ +export function formatRange(range: VersionRange): string { + let ver = `${range.major}.${range.minor}.${range.patch}`; + switch (range.type) { + case "caret": + return `^${ver}`; + case "tilde": + return `~${ver}`; + case "exact": + return ver; + } +} diff --git a/cli/tests/semver.test.ts b/cli/tests/semver.test.ts new file mode 100644 index 00000000..f63d99b7 --- /dev/null +++ b/cli/tests/semver.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test } from "@jest/globals"; +import { + parseRange, + satisfies, + compareVersions, + upperBound, + highestSatisfying, + isRange, + stripRangePrefix, + formatRange, +} from "../semver"; + +describe("compareVersions", () => { + test("equal versions", () => { + expect(compareVersions("1.2.3", "1.2.3")).toBe(0); + }); + + test("major difference", () => { + expect(compareVersions("2.0.0", "1.0.0")).toBe(1); + expect(compareVersions("1.0.0", "2.0.0")).toBe(-1); + }); + + test("minor difference", () => { + expect(compareVersions("1.3.0", "1.2.0")).toBe(1); + expect(compareVersions("1.2.0", "1.3.0")).toBe(-1); + }); + + test("patch difference", () => { + expect(compareVersions("1.2.4", "1.2.3")).toBe(1); + expect(compareVersions("1.2.3", "1.2.4")).toBe(-1); + }); + + test("defaults to 0.0.0", () => { + expect(compareVersions()).toBe(0); + expect(compareVersions("1.0.0")).toBe(1); + }); +}); + +describe("parseRange", () => { + test("exact version", () => { + let r = parseRange("1.2.3"); + expect(r).toEqual({ type: "exact", major: 1, minor: 2, patch: 3 }); + }); + + test("caret range", () => { + let r = parseRange("^1.2.3"); + expect(r).toEqual({ type: "caret", major: 1, minor: 2, patch: 3 }); + }); + + test("tilde range", () => { + let r = parseRange("~1.2.3"); + expect(r).toEqual({ type: "tilde", major: 1, minor: 2, patch: 3 }); + }); + + test("pre-1.0 caret", () => { + let r = parseRange("^0.2.3"); + expect(r).toEqual({ type: "caret", major: 0, minor: 2, patch: 3 }); + }); +}); + +describe("isRange", () => { + test("caret is range", () => expect(isRange("^1.0.0")).toBe(true)); + test("tilde is range", () => expect(isRange("~1.0.0")).toBe(true)); + test("exact is not range", () => expect(isRange("1.0.0")).toBe(false)); +}); + +describe("stripRangePrefix", () => { + test("strips caret", () => expect(stripRangePrefix("^1.0.0")).toBe("1.0.0")); + test("strips tilde", () => expect(stripRangePrefix("~1.0.0")).toBe("1.0.0")); + test("keeps exact", () => expect(stripRangePrefix("1.0.0")).toBe("1.0.0")); +}); + +describe("upperBound", () => { + test("caret ^1.2.3 → <2.0.0", () => { + expect(upperBound(parseRange("^1.2.3"))).toEqual([2, 0, 0]); + }); + + test("caret ^0.2.3 → <0.3.0 (pre-1.0)", () => { + expect(upperBound(parseRange("^0.2.3"))).toEqual([0, 3, 0]); + }); + + test("caret ^0.0.3 → <0.0.4 (pre-0.1)", () => { + expect(upperBound(parseRange("^0.0.3"))).toEqual([0, 0, 4]); + }); + + test("tilde ~1.2.3 → <1.3.0", () => { + expect(upperBound(parseRange("~1.2.3"))).toEqual([1, 3, 0]); + }); + + test("tilde ~0.2.3 → <0.3.0", () => { + expect(upperBound(parseRange("~0.2.3"))).toEqual([0, 3, 0]); + }); + + test("exact 1.2.3 → <1.2.4", () => { + expect(upperBound(parseRange("1.2.3"))).toEqual([1, 2, 4]); + }); +}); + +describe("satisfies", () => { + describe("caret (^)", () => { + let range = parseRange("^1.2.3"); + + test("exact match", () => expect(satisfies("1.2.3", range)).toBe(true)); + test("higher patch", () => expect(satisfies("1.2.5", range)).toBe(true)); + test("higher minor", () => expect(satisfies("1.5.0", range)).toBe(true)); + test("highest before next major", () => + expect(satisfies("1.99.99", range)).toBe(true)); + test("next major", () => expect(satisfies("2.0.0", range)).toBe(false)); + test("lower patch", () => expect(satisfies("1.2.2", range)).toBe(false)); + test("lower minor", () => expect(satisfies("1.1.0", range)).toBe(false)); + test("lower major", () => expect(satisfies("0.9.0", range)).toBe(false)); + }); + + describe("caret pre-1.0 (^0.x)", () => { + let range = parseRange("^0.2.3"); + + test("exact match", () => expect(satisfies("0.2.3", range)).toBe(true)); + test("higher patch", () => expect(satisfies("0.2.9", range)).toBe(true)); + test("next minor", () => expect(satisfies("0.3.0", range)).toBe(false)); + test("lower patch", () => expect(satisfies("0.2.2", range)).toBe(false)); + }); + + describe("caret pre-0.1 (^0.0.x)", () => { + let range = parseRange("^0.0.3"); + + test("exact match", () => expect(satisfies("0.0.3", range)).toBe(true)); + test("next patch", () => expect(satisfies("0.0.4", range)).toBe(false)); + test("lower patch", () => expect(satisfies("0.0.2", range)).toBe(false)); + }); + + describe("tilde (~)", () => { + let range = parseRange("~1.2.3"); + + test("exact match", () => expect(satisfies("1.2.3", range)).toBe(true)); + test("higher patch", () => expect(satisfies("1.2.9", range)).toBe(true)); + test("next minor", () => expect(satisfies("1.3.0", range)).toBe(false)); + test("lower patch", () => expect(satisfies("1.2.2", range)).toBe(false)); + }); + + describe("exact", () => { + let range = parseRange("1.2.3"); + + test("exact match", () => expect(satisfies("1.2.3", range)).toBe(true)); + test("higher patch", () => expect(satisfies("1.2.4", range)).toBe(false)); + test("lower patch", () => expect(satisfies("1.2.2", range)).toBe(false)); + }); +}); + +describe("highestSatisfying", () => { + let versions = [ + "0.9.0", + "1.0.0", + "1.1.0", + "1.2.0", + "1.2.3", + "1.5.0", + "1.99.0", + "2.0.0", + "2.1.0", + ]; + + test("caret finds highest within major", () => { + expect(highestSatisfying(versions, parseRange("^1.2.0"))).toBe("1.99.0"); + }); + + test("tilde finds highest within minor", () => { + expect(highestSatisfying(versions, parseRange("~1.2.0"))).toBe("1.2.3"); + }); + + test("exact finds exact match", () => { + expect(highestSatisfying(versions, parseRange("1.2.3"))).toBe("1.2.3"); + }); + + test("returns undefined when no match", () => { + expect(highestSatisfying(versions, parseRange("^3.0.0"))).toBeUndefined(); + }); + + test("pre-1.0 caret respects minor boundary", () => { + let pre1 = ["0.1.0", "0.2.0", "0.2.5", "0.3.0"]; + expect(highestSatisfying(pre1, parseRange("^0.2.0"))).toBe("0.2.5"); + }); +}); + +describe("formatRange", () => { + test("formats caret", () => { + expect(formatRange({ type: "caret", major: 1, minor: 2, patch: 3 })).toBe( + "^1.2.3", + ); + }); + + test("formats tilde", () => { + expect(formatRange({ type: "tilde", major: 1, minor: 2, patch: 3 })).toBe( + "~1.2.3", + ); + }); + + test("formats exact", () => { + expect(formatRange({ type: "exact", major: 1, minor: 2, patch: 3 })).toBe( + "1.2.3", + ); + }); +}); diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 045d1d10..24a501a6 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -36,10 +36,32 @@ Make sure there is no `/tree/main/` in the URL. | Field | Description | | --------------------- | ----------------------------------------------- | -| ``
Example: `base` | Version in format x.y.z (e.g. `0.1.2`) | -| `@`
Example: `base@0.11.0` | Version in format x.y.z (e.g. `0.1.2`) | +| ``
Example: `core` | Exact version `x.y.z` (e.g. `0.1.2`), caret range `^x.y.z`, or tilde range `~x.y.z` | +| `@`
Example: `core@0.11.0` | Version in format x.y.z (e.g. `0.1.2`) | | ``
Example: `shared` | Local path starting with `./`, `../`, or `/`
Example: `./packages/shared` | +### Version ranges + +You can use version range operators to allow compatible version updates: + +```toml +[dependencies] +core = "1.2.3" # exact version +core = "^1.2.3" # caret: >=1.2.3, <2.0.0 (compatible updates) +core = "~1.2.3" # tilde: >=1.2.3, <1.3.0 (patch updates only) +``` + +**Caret (`^`)** allows updates that do not modify the leftmost non-zero component: +- `^1.2.3` = `>=1.2.3, <2.0.0` +- `^0.2.3` = `>=0.2.3, <0.3.0` +- `^0.0.3` = `>=0.0.3, <0.0.4` + +**Tilde (`~`)** allows only patch-level updates: +- `~1.2.3` = `>=1.2.3, <1.3.0` +- `~0.2.3` = `>=0.2.3, <0.3.0` + +When you run `mops add `, the caret range (`^`) is used by default. + :::note GitHub dependencies are not allowed in `[dependencies]`. Please publish the dependency to the Mops registry instead. ::: diff --git a/docs/docs/articles/01-how-dependecy-resolution-works.md b/docs/docs/articles/01-how-dependecy-resolution-works.md index 21111959..9cb87552 100644 --- a/docs/docs/articles/01-how-dependecy-resolution-works.md +++ b/docs/docs/articles/01-how-dependecy-resolution-works.md @@ -5,12 +5,30 @@ sidebar_label: How dependency resolution works # How dependency resolution works -1. Direct dependencies listed in `mops.toml` are always resolved to the specified version. +1. Direct dependencies listed in `mops.toml` are always resolved to the specified version (or highest satisfying version for ranges). _Only for project's root `mops.toml` file. Does not apply to `mops.toml` files of dependencies_ 2. Compatible transitive dependency versions are resolved to the highest version in the dependency graph. -3. Incompatible transitive dependency versions are reported as warnings. +3. Incompatible transitive dependency versions are reported as errors. + +4. When a version range is used (e.g. `^1.2.3`), all transitive constraints must be satisfiable. If a root dependency pins an exact version that is too low for a transitive dependency's range, the resolver will report an error. + + +### Version ranges + +Dependencies can specify exact versions or version ranges: + +```toml +[dependencies] +core = "1.2.3" # exact: only 1.2.3 +core = "^1.2.3" # caret: >=1.2.3, <2.0.0 +core = "~1.2.3" # tilde: >=1.2.3, <1.3.0 +``` + +The **caret** (`^`) allows updates that do not change the leftmost non-zero component. This is the default when adding packages with `mops add`. + +The **tilde** (`~`) allows only patch-level updates within the same minor version. ### Version compatibility @@ -22,8 +40,14 @@ For example: - `1.0.0` and `1.1.0` are compatible - `0.1.0` and `0.23.0` are compatible +### Lock file + +The `mops.lock` file records the exact resolved versions for all dependencies. When a lock file is present and up to date, `mops install` uses the locked versions directly without re-resolving ranges. + +Run `mops update` to re-resolve ranges and update the lock file with the latest compatible versions. + ### Unwanted dependency changes If you don't change the version of a direct dependency, the version of the transitive dependencies will not change. -So, unchanged `mops.toml` - unchanged dependency graph. \ No newline at end of file +So, unchanged `mops.toml` - unchanged dependency graph. diff --git a/docs/docs/cli/1-deps/01-mops-add.md b/docs/docs/cli/1-deps/01-mops-add.md index 8362bc8b..2722eada 100644 --- a/docs/docs/cli/1-deps/01-mops-add.md +++ b/docs/docs/cli/1-deps/01-mops-add.md @@ -12,14 +12,22 @@ mops add ### Examples -Install latest `base` package from `mops` registry +Install latest version of a package with a caret range (default) ``` -mops add base +mops add core ``` +This writes `core = "^x.y.z"` to `mops.toml`, allowing compatible updates. -Install specific version of `base` package from `mops` registry +Install a specific exact version ``` -mops add base@0.10.0 +mops add core@1.2.0 +``` +This writes `core = "1.2.0"` to `mops.toml` (exact pin). + +Install with a specific range +``` +mops add core@^1.2.0 +mops add core@~1.2.0 ``` Add package from GitHub From cbb62d503dacf9c37b227f962b89850cfb5d3e73 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 14 Apr 2026 18:10:20 +0200 Subject: [PATCH 2/8] refactor: replace hand-rolled semver with existing npm semver package - Reduce cli/semver.ts from 135 to 14 lines (thin wrapper with isRange/stripRangePrefix) - Use semver.maxSatisfying, semver.satisfies, semver.compare from the already-installed semver@7.7.1 - Cache directory listing in findCachedVersions to avoid N redundant readdirSync calls - Remove redundant stripRangePrefix call on exact deps in available-updates.ts - Slim tests from 202 to 47 lines (only test mops-specific helpers, not npm semver semantics) Made-with: Cursor --- cli/cache.ts | 13 +- cli/commands/available-updates.ts | 12 +- cli/commands/install/install-mops-dep.ts | 12 +- cli/resolve-packages.ts | 22 +-- cli/semver.ts | 125 +------------- cli/tests/semver.test.ts | 199 +++-------------------- 6 files changed, 46 insertions(+), 337 deletions(-) diff --git a/cli/cache.ts b/cli/cache.ts index 457f4907..47b42a7a 100644 --- a/cli/cache.ts +++ b/cli/cache.ts @@ -77,12 +77,17 @@ export let copyCache = (cacheName: string, dest: string) => { }); }; +let cachedDirEntries: string[] | null = null; + export function findCachedVersions(name: string): string[] { - let packagesDir = path.join(getGlobalCacheDir(), "packages"); - if (!fs.existsSync(packagesDir)) return []; + if (!cachedDirEntries) { + let packagesDir = path.join(getGlobalCacheDir(), "packages"); + cachedDirEntries = fs.existsSync(packagesDir) + ? fs.readdirSync(packagesDir) + : []; + } let prefix = name + "@"; - return fs - .readdirSync(packagesDir) + return cachedDirEntries .filter((entry) => entry.startsWith(prefix)) .map((entry) => entry.slice(prefix.length)); } diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index 0575de85..8c942754 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -5,12 +5,7 @@ import { getPackageVersions } from "../api/getPackageVersions.js"; import { Config } from "../types.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; import { SemverPart } from "../declarations/main/main.did.js"; -import { - isRange, - parseRange, - highestSatisfying, - stripRangePrefix, -} from "../semver.js"; +import { semver, isRange, stripRangePrefix } from "../semver.js"; // [pkg, oldVersion, newVersion] export async function getAvailableUpdates( @@ -52,10 +47,9 @@ export async function getAvailableUpdates( let rangedResults = await Promise.all( rangedDeps.map(async (dep) => { let name = getDepName(dep.name); - let range = parseRange(dep.version || ""); let versionsRes = await getPackageVersions(name); if ("err" in versionsRes) return null; - let highest = highestSatisfying(versionsRes.ok, range); + let highest = semver.maxSatisfying(versionsRes.ok, dep.version || ""); return highest ? ([name, highest] as [string, string]) : null; }), ); @@ -77,7 +71,7 @@ export async function getAvailableUpdates( ? { minor: null } : { patch: null }; } - return [name, stripRangePrefix(dep.version || ""), semverPart]; + return [name, dep.version || "", semverPart]; }), ); diff --git a/cli/commands/install/install-mops-dep.ts b/cli/commands/install/install-mops-dep.ts index 2db9b53c..6c816fb5 100644 --- a/cli/commands/install/install-mops-dep.ts +++ b/cli/commands/install/install-mops-dep.ts @@ -21,12 +21,7 @@ import { } from "../../api/downloadPackageFiles.js"; import { installDeps } from "./install-deps.js"; import { getDepName } from "../../helpers/get-dep-name.js"; -import { - isRange, - parseRange, - highestSatisfying, - formatRange, -} from "../../semver.js"; +import { semver, isRange } from "../../semver.js"; type InstallMopsDepOptions = { verbose?: boolean; @@ -75,17 +70,16 @@ export async function installMopsDep( } version = versionRes.ok; } else if (isRange(version)) { - let range = parseRange(version); let versionsRes = await getPackageVersions(depName); if ("err" in versionsRes) { console.log(chalk.red("Error: ") + versionsRes.err); return false; } - let resolved = highestSatisfying(versionsRes.ok, range); + let resolved = semver.maxSatisfying(versionsRes.ok, version); if (!resolved) { console.log( chalk.red("Error: ") + - `No version of "${depName}" satisfies ${formatRange(range)}`, + `No version of "${depName}" satisfies ${version}`, ); return false; } diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index f4257801..895a3537 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -17,14 +17,7 @@ import { } from "./cache.js"; import { getPackageId } from "./helpers/get-package-id.js"; import { checkLockFileLight, readLockFile } from "./integrity.js"; -import { - compareVersions, - highestSatisfying, - isRange, - parseRange, - satisfies, - stripRangePrefix, -} from "./semver.js"; +import { semver, isRange, stripRangePrefix } from "./semver.js"; type VersionConstraint = { isMopsPackage: boolean; @@ -48,8 +41,7 @@ function resolveRangeFromCache( } let installed = findCachedVersions(name); - let best = highestSatisfying(installed, parseRange(version)); - let resolved = best || bareVersion; + let resolved = semver.maxSatisfying(installed, version) || bareVersion; cache.set(key, resolved); return resolved; } @@ -81,7 +73,7 @@ export async function resolvePackages({ const { branch: b } = parseGithubURL(repoB); if (gitVerRegex.test(a) && gitVerRegex.test(b)) { - return compareVersions(a.substring(1), b.substring(1)); + return semver.compare(a.substring(1), b.substring(1)); } else if (!gitVerRegex.test(a)) { return -1; } else { @@ -119,7 +111,10 @@ export async function resolvePackages({ ((repo && packages[name]?.repo && compareGitVersions(packages[name]?.repo || "", repo) === -1) || - compareVersions(resolvedExisting, resolvedCurrent) === -1)) + semver.compare( + resolvedExisting || "0.0.0", + resolvedCurrent || "0.0.0", + ) === -1)) ) { let temp = { ...pkgDetails, @@ -233,8 +228,7 @@ export async function resolvePackages({ ); for (let constraint of mopsVers) { if (isRange(constraint.version)) { - let range = parseRange(constraint.version); - if (!satisfies(resolvedExact, range)) { + if (!semver.satisfies(resolvedExact, constraint.version)) { console.error( chalk.reset("") + chalk.redBright( diff --git a/cli/semver.ts b/cli/semver.ts index 6c200ef9..d533c6a9 100644 --- a/cli/semver.ts +++ b/cli/semver.ts @@ -1,43 +1,6 @@ -export type RangeType = "exact" | "caret" | "tilde"; +import semver from "semver"; -export type VersionRange = { - type: RangeType; - major: number; - minor: number; - patch: number; -}; - -export type ParsedVersion = [number, number, number]; - -export function parseVersion(ver: string): ParsedVersion { - let parts = ver.split(".").map((x) => parseInt(x) || 0); - return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; -} - -export function compareVersions( - a: string = "0.0.0", - b: string = "0.0.0", -): number { - let [a0, a1, a2] = parseVersion(a); - let [b0, b1, b2] = parseVersion(b); - return Math.sign(a0 - b0) || Math.sign(a1 - b1) || Math.sign(a2 - b2); -} - -export function parseRange(spec: string): VersionRange { - let type: RangeType = "exact"; - let versionStr = spec; - - if (spec.startsWith("^")) { - type = "caret"; - versionStr = spec.slice(1); - } else if (spec.startsWith("~")) { - type = "tilde"; - versionStr = spec.slice(1); - } - - let [major, minor, patch] = parseVersion(versionStr); - return { type, major, minor, patch }; -} +export { semver }; export function isRange(spec: string): boolean { return spec.startsWith("^") || spec.startsWith("~"); @@ -49,87 +12,3 @@ export function stripRangePrefix(spec: string): string { } return spec; } - -/** - * Compute the exclusive upper bound for a range. - * - * Caret (^) — leftmost non-zero component is the compatibility boundary: - * ^1.2.3 → <2.0.0 - * ^0.2.3 → <0.3.0 - * ^0.0.3 → <0.0.4 - * - * Tilde (~) — next minor is the boundary: - * ~1.2.3 → <1.3.0 - * ~0.2.3 → <0.3.0 - */ -export function upperBound(range: VersionRange): ParsedVersion { - let { type, major, minor, patch } = range; - - if (type === "exact") { - return [major, minor, patch + 1]; - } - - if (type === "tilde") { - return [major, minor + 1, 0]; - } - - // caret: leftmost non-zero component is the boundary - if (major !== 0) { - return [major + 1, 0, 0]; - } - if (minor !== 0) { - return [0, minor + 1, 0]; - } - return [0, 0, patch + 1]; -} - -export function lowerBound(range: VersionRange): ParsedVersion { - return [range.major, range.minor, range.patch]; -} - -function versionGte(v: ParsedVersion, bound: ParsedVersion): boolean { - return compareVersions(v.join("."), bound.join(".")) >= 0; -} - -function versionLt(v: ParsedVersion, bound: ParsedVersion): boolean { - return compareVersions(v.join("."), bound.join(".")) < 0; -} - -export function satisfies(version: string, range: VersionRange): boolean { - let v = parseVersion(version); - let lo = lowerBound(range); - let hi = upperBound(range); - return versionGte(v, lo) && versionLt(v, hi); -} - -/** - * Find the highest version from a list that satisfies the given range. - * Returns undefined if no version satisfies. - */ -export function highestSatisfying( - versions: string[], - range: VersionRange, -): string | undefined { - let best: string | undefined; - for (let v of versions) { - if (satisfies(v, range) && (!best || compareVersions(v, best) > 0)) { - best = v; - } - } - return best; -} - -/** - * Format a range back to its string representation. - */ -export function formatRange(range: VersionRange): string { - let ver = `${range.major}.${range.minor}.${range.patch}`; - switch (range.type) { - case "caret": - return `^${ver}`; - case "tilde": - return `~${ver}`; - case "exact": - return ver; - } -} diff --git a/cli/tests/semver.test.ts b/cli/tests/semver.test.ts index f63d99b7..84710163 100644 --- a/cli/tests/semver.test.ts +++ b/cli/tests/semver.test.ts @@ -1,202 +1,45 @@ import { describe, expect, test } from "@jest/globals"; -import { - parseRange, - satisfies, - compareVersions, - upperBound, - highestSatisfying, - isRange, - stripRangePrefix, - formatRange, -} from "../semver"; - -describe("compareVersions", () => { - test("equal versions", () => { - expect(compareVersions("1.2.3", "1.2.3")).toBe(0); - }); - - test("major difference", () => { - expect(compareVersions("2.0.0", "1.0.0")).toBe(1); - expect(compareVersions("1.0.0", "2.0.0")).toBe(-1); - }); - - test("minor difference", () => { - expect(compareVersions("1.3.0", "1.2.0")).toBe(1); - expect(compareVersions("1.2.0", "1.3.0")).toBe(-1); - }); - - test("patch difference", () => { - expect(compareVersions("1.2.4", "1.2.3")).toBe(1); - expect(compareVersions("1.2.3", "1.2.4")).toBe(-1); - }); - - test("defaults to 0.0.0", () => { - expect(compareVersions()).toBe(0); - expect(compareVersions("1.0.0")).toBe(1); - }); -}); - -describe("parseRange", () => { - test("exact version", () => { - let r = parseRange("1.2.3"); - expect(r).toEqual({ type: "exact", major: 1, minor: 2, patch: 3 }); - }); - - test("caret range", () => { - let r = parseRange("^1.2.3"); - expect(r).toEqual({ type: "caret", major: 1, minor: 2, patch: 3 }); - }); - - test("tilde range", () => { - let r = parseRange("~1.2.3"); - expect(r).toEqual({ type: "tilde", major: 1, minor: 2, patch: 3 }); - }); - - test("pre-1.0 caret", () => { - let r = parseRange("^0.2.3"); - expect(r).toEqual({ type: "caret", major: 0, minor: 2, patch: 3 }); - }); -}); +import { semver, isRange, stripRangePrefix } from "../semver"; describe("isRange", () => { test("caret is range", () => expect(isRange("^1.0.0")).toBe(true)); test("tilde is range", () => expect(isRange("~1.0.0")).toBe(true)); test("exact is not range", () => expect(isRange("1.0.0")).toBe(false)); + test("empty is not range", () => expect(isRange("")).toBe(false)); }); describe("stripRangePrefix", () => { test("strips caret", () => expect(stripRangePrefix("^1.0.0")).toBe("1.0.0")); test("strips tilde", () => expect(stripRangePrefix("~1.0.0")).toBe("1.0.0")); test("keeps exact", () => expect(stripRangePrefix("1.0.0")).toBe("1.0.0")); + test("keeps empty", () => expect(stripRangePrefix("")).toBe("")); }); -describe("upperBound", () => { - test("caret ^1.2.3 → <2.0.0", () => { - expect(upperBound(parseRange("^1.2.3"))).toEqual([2, 0, 0]); - }); - - test("caret ^0.2.3 → <0.3.0 (pre-1.0)", () => { - expect(upperBound(parseRange("^0.2.3"))).toEqual([0, 3, 0]); +describe("semver re-export works for version ranges", () => { + test("satisfies caret", () => { + expect(semver.satisfies("1.5.0", "^1.2.3")).toBe(true); + expect(semver.satisfies("2.0.0", "^1.2.3")).toBe(false); }); - test("caret ^0.0.3 → <0.0.4 (pre-0.1)", () => { - expect(upperBound(parseRange("^0.0.3"))).toEqual([0, 0, 4]); + test("satisfies tilde", () => { + expect(semver.satisfies("1.2.9", "~1.2.3")).toBe(true); + expect(semver.satisfies("1.3.0", "~1.2.3")).toBe(false); }); - test("tilde ~1.2.3 → <1.3.0", () => { - expect(upperBound(parseRange("~1.2.3"))).toEqual([1, 3, 0]); - }); - - test("tilde ~0.2.3 → <0.3.0", () => { - expect(upperBound(parseRange("~0.2.3"))).toEqual([0, 3, 0]); - }); - - test("exact 1.2.3 → <1.2.4", () => { - expect(upperBound(parseRange("1.2.3"))).toEqual([1, 2, 4]); - }); -}); - -describe("satisfies", () => { - describe("caret (^)", () => { - let range = parseRange("^1.2.3"); - - test("exact match", () => expect(satisfies("1.2.3", range)).toBe(true)); - test("higher patch", () => expect(satisfies("1.2.5", range)).toBe(true)); - test("higher minor", () => expect(satisfies("1.5.0", range)).toBe(true)); - test("highest before next major", () => - expect(satisfies("1.99.99", range)).toBe(true)); - test("next major", () => expect(satisfies("2.0.0", range)).toBe(false)); - test("lower patch", () => expect(satisfies("1.2.2", range)).toBe(false)); - test("lower minor", () => expect(satisfies("1.1.0", range)).toBe(false)); - test("lower major", () => expect(satisfies("0.9.0", range)).toBe(false)); - }); - - describe("caret pre-1.0 (^0.x)", () => { - let range = parseRange("^0.2.3"); - - test("exact match", () => expect(satisfies("0.2.3", range)).toBe(true)); - test("higher patch", () => expect(satisfies("0.2.9", range)).toBe(true)); - test("next minor", () => expect(satisfies("0.3.0", range)).toBe(false)); - test("lower patch", () => expect(satisfies("0.2.2", range)).toBe(false)); - }); - - describe("caret pre-0.1 (^0.0.x)", () => { - let range = parseRange("^0.0.3"); - - test("exact match", () => expect(satisfies("0.0.3", range)).toBe(true)); - test("next patch", () => expect(satisfies("0.0.4", range)).toBe(false)); - test("lower patch", () => expect(satisfies("0.0.2", range)).toBe(false)); - }); - - describe("tilde (~)", () => { - let range = parseRange("~1.2.3"); - - test("exact match", () => expect(satisfies("1.2.3", range)).toBe(true)); - test("higher patch", () => expect(satisfies("1.2.9", range)).toBe(true)); - test("next minor", () => expect(satisfies("1.3.0", range)).toBe(false)); - test("lower patch", () => expect(satisfies("1.2.2", range)).toBe(false)); - }); - - describe("exact", () => { - let range = parseRange("1.2.3"); - - test("exact match", () => expect(satisfies("1.2.3", range)).toBe(true)); - test("higher patch", () => expect(satisfies("1.2.4", range)).toBe(false)); - test("lower patch", () => expect(satisfies("1.2.2", range)).toBe(false)); - }); -}); - -describe("highestSatisfying", () => { - let versions = [ - "0.9.0", - "1.0.0", - "1.1.0", - "1.2.0", - "1.2.3", - "1.5.0", - "1.99.0", - "2.0.0", - "2.1.0", - ]; - - test("caret finds highest within major", () => { - expect(highestSatisfying(versions, parseRange("^1.2.0"))).toBe("1.99.0"); - }); - - test("tilde finds highest within minor", () => { - expect(highestSatisfying(versions, parseRange("~1.2.0"))).toBe("1.2.3"); - }); - - test("exact finds exact match", () => { - expect(highestSatisfying(versions, parseRange("1.2.3"))).toBe("1.2.3"); - }); - - test("returns undefined when no match", () => { - expect(highestSatisfying(versions, parseRange("^3.0.0"))).toBeUndefined(); - }); - - test("pre-1.0 caret respects minor boundary", () => { - let pre1 = ["0.1.0", "0.2.0", "0.2.5", "0.3.0"]; - expect(highestSatisfying(pre1, parseRange("^0.2.0"))).toBe("0.2.5"); - }); -}); - -describe("formatRange", () => { - test("formats caret", () => { - expect(formatRange({ type: "caret", major: 1, minor: 2, patch: 3 })).toBe( - "^1.2.3", - ); + test("satisfies pre-1.0 caret", () => { + expect(semver.satisfies("0.2.5", "^0.2.3")).toBe(true); + expect(semver.satisfies("0.3.0", "^0.2.3")).toBe(false); }); - test("formats tilde", () => { - expect(formatRange({ type: "tilde", major: 1, minor: 2, patch: 3 })).toBe( - "~1.2.3", - ); + test("maxSatisfying picks highest within range", () => { + let versions = ["1.0.0", "1.2.0", "1.5.0", "1.99.0", "2.0.0"]; + expect(semver.maxSatisfying(versions, "^1.2.0")).toBe("1.99.0"); + expect(semver.maxSatisfying(versions, "~1.2.0")).toBe("1.2.0"); }); - test("formats exact", () => { - expect(formatRange({ type: "exact", major: 1, minor: 2, patch: 3 })).toBe( - "1.2.3", - ); + test("compare returns -1, 0, 1", () => { + expect(semver.compare("1.0.0", "2.0.0")).toBe(-1); + expect(semver.compare("1.0.0", "1.0.0")).toBe(0); + expect(semver.compare("2.0.0", "1.0.0")).toBe(1); }); }); From 7fb095697c87dc1198802129b73cf07ce692f4ad Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 14 Apr 2026 18:16:31 +0200 Subject: [PATCH 3/8] fix: compare against locked version for ranged deps in available-updates - Use lock file resolved version (not range floor) when checking if ranged deps have available updates - Add resetCachedDirEntries() and invalidate before resolvePackages to ensure freshly installed packages are visible Made-with: Cursor --- cli/cache.ts | 4 ++++ cli/commands/available-updates.ts | 19 ++++++++++++++++--- cli/resolve-packages.ts | 4 ++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/cli/cache.ts b/cli/cache.ts index 47b42a7a..06c9d4e9 100644 --- a/cli/cache.ts +++ b/cli/cache.ts @@ -79,6 +79,10 @@ export let copyCache = (cacheName: string, dest: string) => { let cachedDirEntries: string[] | null = null; +export function resetCachedDirEntries() { + cachedDirEntries = null; +} + export function findCachedVersions(name: string): string[] { if (!cachedDirEntries) { let packagesDir = path.join(getGlobalCacheDir(), "packages"); diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index 8c942754..5180e829 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -6,6 +6,7 @@ import { Config } from "../types.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; import { SemverPart } from "../declarations/main/main.did.js"; import { semver, isRange, stripRangePrefix } from "../semver.js"; +import { checkLockFileLight, readLockFile } from "../integrity.js"; // [pkg, oldVersion, newVersion] export async function getAvailableUpdates( @@ -37,7 +38,15 @@ export async function getAvailableUpdates( return ""; }; - // Split into ranged and non-ranged deps + // Read lock file for comparing currently resolved versions + let lockedDeps: Record = {}; + if (checkLockFileLight()) { + let lockFileJson = readLockFile(); + if (lockFileJson && lockFileJson.version === 3) { + lockedDeps = lockFileJson.deps; + } + } + let rangedDeps = depsToUpdate.filter((dep) => isRange(dep.version || "")); let exactDeps = depsToUpdate.filter((dep) => !isRange(dep.version || "")); @@ -85,8 +94,12 @@ export async function getAvailableUpdates( return results .filter((dep) => { - let current = getCurrentVersion(dep[0], dep[1]); - return stripRangePrefix(current) !== dep[1]; + let currentConfigVer = getCurrentVersion(dep[0], dep[1]); + // For ranged deps, compare against the locked/resolved version + let currentResolved = isRange(currentConfigVer) + ? lockedDeps[dep[0]] || stripRangePrefix(currentConfigVer) + : currentConfigVer; + return currentResolved !== dep[1]; }) .map((dep) => [ dep[0], diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index 895a3537..da51e1e2 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -14,6 +14,7 @@ import { findCachedVersions, getDepCacheDir, getDepCacheName, + resetCachedDirEntries, } from "./cache.js"; import { getPackageId } from "./helpers/get-package-id.js"; import { checkLockFileLight, readLockFile } from "./integrity.js"; @@ -61,6 +62,9 @@ export async function resolvePackages({ } } + // Invalidate cached dir listing so we pick up freshly installed packages + resetCachedDirEntries(); + let rootDir = getRootDir(); let packages: Record = {}; let versions: Record> = {}; From 53619dca58a3a37997f494853abeb9aec3fcb107 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 14 Apr 2026 18:18:44 +0200 Subject: [PATCH 4/8] docs: add changelog entry for version ranges Made-with: Cursor --- cli/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index be23f2fd..3767666c 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,6 +1,11 @@ # Mops CLI Changelog ## Next +- Add version range support for dependencies: caret (`^1.2.3`) and tilde (`~1.2.3`) operators +- `mops add` now defaults to caret range (e.g. `mops add core` writes `core = "^1.2.3"`) +- `mops add core@1.2.3` still pins an exact version +- `mops update` preserves range type when bumping versions +- Resolver validates that all range constraints are satisfied and reports errors on conflicts ## 2.10.0 - `mops check` and `mops check-stable` now apply per-canister `[canisters.].args` (previously only `mops build` applied them) From b7dfeaa8c7fdf54572f2c85b7596fb1fb899b14b Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 14 Apr 2026 18:24:11 +0200 Subject: [PATCH 5/8] refactor: remove getPackageVersions, reuse getHighestSemverBatch for ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing getHighestSemverBatch endpoint already covers range resolution — rangeToSemverPart maps ^/~ to the correct SemverPart (#major, #minor, #patch). This eliminates a new backend endpoint, the N+1 call problem, and the dedicated API wrapper. Made-with: Cursor --- backend/main/main-canister.mo | 4 -- cli/api/getPackageVersions.ts | 6 -- cli/commands/available-updates.ts | 72 ++++++++++-------------- cli/commands/install/install-mops-dep.ts | 28 +++++---- cli/declarations/main/main.did | 6 -- cli/declarations/main/main.did.d.ts | 3 - cli/declarations/main/main.did.js | 2 - cli/semver.ts | 24 ++++++++ cli/tests/semver.test.ts | 25 +++++++- 9 files changed, 90 insertions(+), 80 deletions(-) delete mode 100644 cli/api/getPackageVersions.ts diff --git a/backend/main/main-canister.mo b/backend/main/main-canister.mo index 978feeb5..bf360bcc 100644 --- a/backend/main/main-canister.mo +++ b/backend/main/main-canister.mo @@ -267,10 +267,6 @@ actor class Main() = this { Result.fromOption(registry.getHighestVersion(name), "Package '" # name # "' not found"); }; - public query func getPackageVersions(name : PackageName) : async Result.Result<[PackageVersion], Err> { - Result.fromOption(registry.getPackageVersions(name), "Package '" # name # "' not found"); - }; - func _getHighestSemver(name : PackageName, currentVersion : PackageVersion, semverPart : SemverPart) : Result.Result { let packageId = PackageUtils.getPackageId(name, currentVersion); if (packageConfigs.get(packageId) == null) { diff --git a/cli/api/getPackageVersions.ts b/cli/api/getPackageVersions.ts deleted file mode 100644 index b650dadc..00000000 --- a/cli/api/getPackageVersions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { mainActor } from "./actors.js"; - -export async function getPackageVersions(pkgName: string) { - let actor = await mainActor(); - return actor.getPackageVersions(pkgName); -} diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index 5180e829..a122ea90 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -1,11 +1,14 @@ import process from "node:process"; import chalk from "chalk"; import { mainActor } from "../api/actors.js"; -import { getPackageVersions } from "../api/getPackageVersions.js"; import { Config } from "../types.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; import { SemverPart } from "../declarations/main/main.did.js"; -import { semver, isRange, stripRangePrefix } from "../semver.js"; +import { + isRange, + stripRangePrefix, + rangeToSemverPart, +} from "../semver.js"; import { checkLockFileLight, readLockFile } from "../integrity.js"; // [pkg, oldVersion, newVersion] @@ -25,6 +28,8 @@ export async function getAvailableUpdates( getDepPinnedVersion(dep.name).split(".").length !== 3, ); + if (depsToUpdate.length === 0) return []; + let getCurrentVersion = (pkg: string, updateVersion: string) => { for (let dep of allDeps) { if (getDepName(dep.name) === pkg && dep.version) { @@ -38,7 +43,6 @@ export async function getAvailableUpdates( return ""; }; - // Read lock file for comparing currently resolved versions let lockedDeps: Record = {}; if (checkLockFileLight()) { let lockFileJson = readLockFile(); @@ -47,55 +51,37 @@ export async function getAvailableUpdates( } } - let rangedDeps = depsToUpdate.filter((dep) => isRange(dep.version || "")); - let exactDeps = depsToUpdate.filter((dep) => !isRange(dep.version || "")); + // Single batch call for all deps (both ranged and exact) + let actor = await mainActor(); + let res = await actor.getHighestSemverBatch( + depsToUpdate.map((dep) => { + let name = getDepName(dep.name); + let version = dep.version || ""; - let results: Array<[string, string]> = []; + if (isRange(version)) { + return [name, stripRangePrefix(version), rangeToSemverPart(version)]; + } - // Resolve ranged deps using getPackageVersions + local filtering - let rangedResults = await Promise.all( - rangedDeps.map(async (dep) => { - let name = getDepName(dep.name); - let versionsRes = await getPackageVersions(name); - if ("err" in versionsRes) return null; - let highest = semver.maxSatisfying(versionsRes.ok, dep.version || ""); - return highest ? ([name, highest] as [string, string]) : null; + let semverPart: SemverPart = { major: null }; + let pinnedVersion = getDepPinnedVersion(dep.name); + if (pinnedVersion) { + semverPart = + pinnedVersion.split(".").length === 1 + ? { minor: null } + : { patch: null }; + } + return [name, version, semverPart]; }), ); - for (let r of rangedResults) { - if (r) results.push(r); - } - - // Resolve exact deps using existing getHighestSemverBatch - if (exactDeps.length > 0) { - let actor = await mainActor(); - let res = await actor.getHighestSemverBatch( - exactDeps.map((dep) => { - let semverPart: SemverPart = { major: null }; - let name = getDepName(dep.name); - let pinnedVersion = getDepPinnedVersion(dep.name); - if (pinnedVersion) { - semverPart = - pinnedVersion.split(".").length === 1 - ? { minor: null } - : { patch: null }; - } - return [name, dep.version || "", semverPart]; - }), - ); - - if ("err" in res) { - console.log(chalk.red("Error:"), res.err); - process.exit(1); - } - results.push(...res.ok); + if ("err" in res) { + console.log(chalk.red("Error:"), res.err); + process.exit(1); } - return results + return res.ok .filter((dep) => { let currentConfigVer = getCurrentVersion(dep[0], dep[1]); - // For ranged deps, compare against the locked/resolved version let currentResolved = isRange(currentConfigVer) ? lockedDeps[dep[0]] || stripRangePrefix(currentConfigVer) : currentConfigVer; diff --git a/cli/commands/install/install-mops-dep.ts b/cli/commands/install/install-mops-dep.ts index 6c816fb5..a7c47052 100644 --- a/cli/commands/install/install-mops-dep.ts +++ b/cli/commands/install/install-mops-dep.ts @@ -7,8 +7,7 @@ import chalk from "chalk"; import { deleteSync } from "del"; import { checkConfigFile, progressBar, readConfig } from "../../mops.js"; import { getHighestVersion } from "../../api/getHighestVersion.js"; -import { getPackageVersions } from "../../api/getPackageVersions.js"; -import { storageActor } from "../../api/actors.js"; +import { mainActor, storageActor } from "../../api/actors.js"; import { parallel } from "../../parallel.js"; import { getDepCacheDir, @@ -21,7 +20,11 @@ import { } from "../../api/downloadPackageFiles.js"; import { installDeps } from "./install-deps.js"; import { getDepName } from "../../helpers/get-dep-name.js"; -import { semver, isRange } from "../../semver.js"; +import { + isRange, + stripRangePrefix, + rangeToSemverPart, +} from "../../semver.js"; type InstallMopsDepOptions = { verbose?: boolean; @@ -70,20 +73,15 @@ export async function installMopsDep( } version = versionRes.ok; } else if (isRange(version)) { - let versionsRes = await getPackageVersions(depName); - if ("err" in versionsRes) { - console.log(chalk.red("Error: ") + versionsRes.err); - return false; - } - let resolved = semver.maxSatisfying(versionsRes.ok, version); - if (!resolved) { - console.log( - chalk.red("Error: ") + - `No version of "${depName}" satisfies ${version}`, - ); + let actor = await mainActor(); + let res = await actor.getHighestSemverBatch([ + [depName, stripRangePrefix(version), rangeToSemverPart(version)], + ]); + if ("err" in res) { + console.log(chalk.red("Error: ") + res.err); return false; } - version = resolved; + version = res.ok[0]?.[1] || stripRangePrefix(version); } let cacheName = getMopsDepCacheName(depName, version); diff --git a/cli/declarations/main/main.did b/cli/declarations/main/main.did index 4f9d4d53..12b6e94c 100644 --- a/cli/declarations/main/main.did +++ b/cli/declarations/main/main.did @@ -60,11 +60,6 @@ type Script = name: text; value: text; }; -type Result_9 = - variant { - err: Err; - ok: vec PackageVersion; - }; type Result_8 = variant { err: Err; @@ -316,7 +311,6 @@ type Main = SemverPart; }) -> (Result_6) query; getHighestVersion: (name: PackageName) -> (Result_5) query; - getPackageVersions: (name: PackageName) -> (Result_9) query; getMostDownloadedPackages: () -> (vec PackageSummary) query; getMostDownloadedPackagesIn7Days: () -> (vec PackageSummary) query; getNewPackages: () -> (vec PackageSummary) query; diff --git a/cli/declarations/main/main.did.d.ts b/cli/declarations/main/main.did.d.ts index b7aab2c9..b83117c6 100644 --- a/cli/declarations/main/main.did.d.ts +++ b/cli/declarations/main/main.did.d.ts @@ -77,7 +77,6 @@ export interface Main { Result_6 >, 'getHighestVersion' : ActorMethod<[PackageName], Result_5>, - 'getPackageVersions' : ActorMethod<[PackageName], Result_9>, 'getMostDownloadedPackages' : ActorMethod<[], Array>, 'getMostDownloadedPackagesIn7Days' : ActorMethod<[], Array>, 'getNewPackages' : ActorMethod<[], Array>, @@ -299,8 +298,6 @@ export type Result_7 = { 'ok' : Array } | { 'err' : Err }; export type Result_8 = { 'ok' : Array<[FileId, Uint8Array | number[]]> } | { 'err' : Err }; -export type Result_9 = { 'ok' : Array } | - { 'err' : Err }; export interface Script { 'value' : string, 'name' : string } export type SemverPart = { 'major' : null } | { 'minor' : null } | diff --git a/cli/declarations/main/main.did.js b/cli/declarations/main/main.did.js index df9416a5..3acbbb06 100644 --- a/cli/declarations/main/main.did.js +++ b/cli/declarations/main/main.did.js @@ -29,7 +29,6 @@ export const idlFactory = ({ IDL }) => { 'err' : Err, }); const Result_5 = IDL.Variant({ 'ok' : PackageVersion, 'err' : Err }); - const Result_9 = IDL.Variant({ 'ok' : IDL.Vec(PackageVersion), 'err' : Err }); const User = IDL.Record({ 'id' : IDL.Principal, 'emailVerified' : IDL.Bool, @@ -309,7 +308,6 @@ export const idlFactory = ({ IDL }) => { ['query'], ), 'getHighestVersion' : IDL.Func([PackageName], [Result_5], ['query']), - 'getPackageVersions' : IDL.Func([PackageName], [Result_9], ['query']), 'getMostDownloadedPackages' : IDL.Func( [], [IDL.Vec(PackageSummary)], diff --git a/cli/semver.ts b/cli/semver.ts index d533c6a9..25cda323 100644 --- a/cli/semver.ts +++ b/cli/semver.ts @@ -1,4 +1,5 @@ import semver from "semver"; +import type { SemverPart } from "./declarations/main/main.did.js"; export { semver }; @@ -12,3 +13,26 @@ export function stripRangePrefix(spec: string): string { } return spec; } + +/** + * Map a range spec to the SemverPart that getHighestSemverBatch expects. + * + * ^1.2.3 (major>0) → #major (highest within same major) + * ^0.2.3 (minor>0) → #minor (highest within same minor) + * ^0.0.3 → #patch (highest within same patch = exact) + * ~X.Y.Z → #minor (highest within same minor) + */ +export function rangeToSemverPart(spec: string): SemverPart { + let bare = stripRangePrefix(spec); + let parsed = semver.parse(bare); + if (!parsed) return { major: null }; + + if (spec.startsWith("~")) { + return { minor: null }; + } + + // caret + if (parsed.major !== 0) return { major: null }; + if (parsed.minor !== 0) return { minor: null }; + return { patch: null }; +} diff --git a/cli/tests/semver.test.ts b/cli/tests/semver.test.ts index 84710163..f3069631 100644 --- a/cli/tests/semver.test.ts +++ b/cli/tests/semver.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "@jest/globals"; -import { semver, isRange, stripRangePrefix } from "../semver"; +import { semver, isRange, stripRangePrefix, rangeToSemverPart } from "../semver"; describe("isRange", () => { test("caret is range", () => expect(isRange("^1.0.0")).toBe(true)); @@ -43,3 +43,26 @@ describe("semver re-export works for version ranges", () => { expect(semver.compare("2.0.0", "1.0.0")).toBe(1); }); }); + +describe("rangeToSemverPart", () => { + test("caret major>0 maps to #major", () => { + expect(rangeToSemverPart("^1.2.3")).toEqual({ major: null }); + expect(rangeToSemverPart("^2.0.0")).toEqual({ major: null }); + }); + + test("caret 0.minor>0 maps to #minor", () => { + expect(rangeToSemverPart("^0.2.3")).toEqual({ minor: null }); + expect(rangeToSemverPart("^0.1.0")).toEqual({ minor: null }); + }); + + test("caret 0.0.z maps to #patch", () => { + expect(rangeToSemverPart("^0.0.3")).toEqual({ patch: null }); + expect(rangeToSemverPart("^0.0.0")).toEqual({ patch: null }); + }); + + test("tilde always maps to #minor", () => { + expect(rangeToSemverPart("~1.2.3")).toEqual({ minor: null }); + expect(rangeToSemverPart("~0.2.3")).toEqual({ minor: null }); + expect(rangeToSemverPart("~0.0.3")).toEqual({ minor: null }); + }); +}); From 28ad33d223341994bc8a827a48592260f1b3c1a1 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 15 Apr 2026 12:10:23 +0200 Subject: [PATCH 6/8] fix: correct rangeToSemverPart mapping, remove backend changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SemverPart mapping was inverted — #major means "any higher" not "same major". Fixed: ^X.Y.Z→#minor, ~X.Y.Z→#patch, ^0.0.x→null (exact pin). Removed all backend canister changes (semver.mo, validateConfig.mo) except the "fromat" typo fix. publish.ts now strips range prefixes before sending config to the backend, so no canister upgrade needed. Also: fix ESLint/Prettier violations, dedupe update.ts loop, remove low-info comments, explicit error on empty batch result. Made-with: Cursor --- backend/main/utils/semver.mo | 21 ------- backend/main/utils/validateConfig.mo | 6 +- cli/commands/add.ts | 1 - cli/commands/available-updates.ts | 80 +++++++++++++----------- cli/commands/install/install-mops-dep.ts | 35 +++++++---- cli/commands/publish.ts | 3 +- cli/commands/update.ts | 29 +++------ cli/resolve-packages.ts | 5 +- cli/semver.ts | 33 ++++++---- cli/tests/semver.test.ts | 37 ++++++----- 10 files changed, 126 insertions(+), 124 deletions(-) diff --git a/backend/main/utils/semver.mo b/backend/main/utils/semver.mo index 99df9db1..54bfdf7b 100644 --- a/backend/main/utils/semver.mo +++ b/backend/main/utils/semver.mo @@ -67,27 +67,6 @@ module { compare(x, y) == #equal; }; - public func stripRangePrefix(ver : Version) : Version { - let chars = ver.chars(); - switch (chars.next()) { - case (?'^') Text.fromIter(chars); - case (?'~') Text.fromIter(chars); - case _ ver; - }; - }; - - public func isRange(ver : Version) : Bool { - switch (ver.chars().next()) { - case (?'^') true; - case (?'~') true; - case _ false; - }; - }; - - public func validateVersionOrRange(ver : Version) : Result.Result<(), Err> { - validate(stripRangePrefix(ver)); - }; - // TODO: support 1.2.3-pre.1 public func validate(ver : Version) : Result.Result<(), Err> { var dots = 0; diff --git a/backend/main/utils/validateConfig.mo b/backend/main/utils/validateConfig.mo index bb73935d..b5746822 100644 --- a/backend/main/utils/validateConfig.mo +++ b/backend/main/utils/validateConfig.mo @@ -248,7 +248,7 @@ module { }; if (dep.repo.size() == 0) { - let versionValid = Semver.validateVersionOrRange(dep.version); + let versionValid = Semver.validate(dep.version); if (Result.isErr(versionValid)) { return versionValid; }; @@ -261,8 +261,8 @@ module { let (_name, aliasVersion) = PackageUtils.parseDepName(dep.name); if (aliasVersion.size() > 0) { - let bareVersion = Semver.stripRangePrefix(dep.version); - if (not Text.startsWith(bareVersion, #text(aliasVersion))) { + // check alias prefix + if (not Text.startsWith(dep.version, #text(aliasVersion))) { return #err("Dependency alias version must be a prefix of the dependency version\nName: " # dep.name # "\nAlias: " # aliasVersion # "\nVersion: " # dep.version); }; }; diff --git a/cli/commands/add.ts b/cli/commands/add.ts index 131351bb..10d09999 100644 --- a/cli/commands/add.ts +++ b/cli/commands/add.ts @@ -148,6 +148,5 @@ export async function add( `${pkgDetails.name} = "${pkgDetails.repo || pkgDetails.path || pkgDetails.version}"`, ); - // check conflicts await resolvePackages({ conflicts: "warning" }); } diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index a122ea90..7123e9b3 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -4,11 +4,7 @@ import { mainActor } from "../api/actors.js"; import { Config } from "../types.js"; import { getDepName, getDepPinnedVersion } from "../helpers/get-dep-name.js"; import { SemverPart } from "../declarations/main/main.did.js"; -import { - isRange, - stripRangePrefix, - rangeToSemverPart, -} from "../semver.js"; +import { isRange, stripRangePrefix, rangeToSemverPart } from "../semver.js"; import { checkLockFileLight, readLockFile } from "../integrity.js"; // [pkg, oldVersion, newVersion] @@ -28,7 +24,9 @@ export async function getAvailableUpdates( getDepPinnedVersion(dep.name).split(".").length !== 3, ); - if (depsToUpdate.length === 0) return []; + if (depsToUpdate.length === 0) { + return []; + } let getCurrentVersion = (pkg: string, updateVersion: string) => { for (let dep of allDeps) { @@ -51,45 +49,51 @@ export async function getAvailableUpdates( } } - // Single batch call for all deps (both ranged and exact) - let actor = await mainActor(); - let res = await actor.getHighestSemverBatch( - depsToUpdate.map((dep) => { - let name = getDepName(dep.name); - let version = dep.version || ""; + let batchItems: Array<[string, string, SemverPart]> = []; + for (let dep of depsToUpdate) { + let name = getDepName(dep.name); + let version = dep.version || ""; - if (isRange(version)) { - return [name, stripRangePrefix(version), rangeToSemverPart(version)]; + if (isRange(version)) { + let part = rangeToSemverPart(version); + if (part) { + batchItems.push([name, stripRangePrefix(version), part]); } + continue; + } - let semverPart: SemverPart = { major: null }; - let pinnedVersion = getDepPinnedVersion(dep.name); - if (pinnedVersion) { - semverPart = - pinnedVersion.split(".").length === 1 - ? { minor: null } - : { patch: null }; - } - return [name, version, semverPart]; - }), - ); + let semverPart: SemverPart = { major: null }; + let pinnedVersion = getDepPinnedVersion(dep.name); + if (pinnedVersion) { + semverPart = + pinnedVersion.split(".").length === 1 + ? { minor: null } + : { patch: null }; + } + batchItems.push([name, version, semverPart]); + } + + if (batchItems.length === 0) { + return []; + } + + let actor = await mainActor(); + let res = await actor.getHighestSemverBatch(batchItems); if ("err" in res) { console.log(chalk.red("Error:"), res.err); process.exit(1); } - return res.ok - .filter((dep) => { - let currentConfigVer = getCurrentVersion(dep[0], dep[1]); - let currentResolved = isRange(currentConfigVer) - ? lockedDeps[dep[0]] || stripRangePrefix(currentConfigVer) - : currentConfigVer; - return currentResolved !== dep[1]; - }) - .map((dep) => [ - dep[0], - getCurrentVersion(dep[0], dep[1]), - dep[1], - ]); + let updates: Array<[string, string, string]> = []; + for (let [name, highestVersion] of res.ok) { + let currentConfigVer = getCurrentVersion(name, highestVersion); + let currentResolved = isRange(currentConfigVer) + ? lockedDeps[name] || stripRangePrefix(currentConfigVer) + : currentConfigVer; + if (currentResolved !== highestVersion) { + updates.push([name, currentConfigVer, highestVersion]); + } + } + return updates; } diff --git a/cli/commands/install/install-mops-dep.ts b/cli/commands/install/install-mops-dep.ts index a7c47052..56c23464 100644 --- a/cli/commands/install/install-mops-dep.ts +++ b/cli/commands/install/install-mops-dep.ts @@ -20,11 +20,7 @@ import { } from "../../api/downloadPackageFiles.js"; import { installDeps } from "./install-deps.js"; import { getDepName } from "../../helpers/get-dep-name.js"; -import { - isRange, - stripRangePrefix, - rangeToSemverPart, -} from "../../semver.js"; +import { isRange, stripRangePrefix, rangeToSemverPart } from "../../semver.js"; type InstallMopsDepOptions = { verbose?: boolean; @@ -73,15 +69,28 @@ export async function installMopsDep( } version = versionRes.ok; } else if (isRange(version)) { - let actor = await mainActor(); - let res = await actor.getHighestSemverBatch([ - [depName, stripRangePrefix(version), rangeToSemverPart(version)], - ]); - if ("err" in res) { - console.log(chalk.red("Error: ") + res.err); - return false; + let part = rangeToSemverPart(version); + if (part) { + let actor = await mainActor(); + let res = await actor.getHighestSemverBatch([ + [depName, stripRangePrefix(version), part], + ]); + if ("err" in res) { + console.log(chalk.red("Error: ") + res.err); + return false; + } + let resolved = res.ok[0]?.[1]; + if (!resolved) { + console.log( + chalk.red("Error: ") + + `No version of "${depName}" satisfies ${version}`, + ); + return false; + } + version = resolved; + } else { + version = stripRangePrefix(version); } - version = res.ok[0]?.[1] || stripRangePrefix(version); } let cacheName = getMopsDepCacheName(depName, version); diff --git a/cli/commands/publish.ts b/cli/commands/publish.ts index 79814c71..afa57234 100644 --- a/cli/commands/publish.ts +++ b/cli/commands/publish.ts @@ -24,6 +24,7 @@ import { Requirement, } from "../declarations/main/main.did.js"; import { Dependency } from "../types.js"; +import { stripRangePrefix } from "../semver.js"; import { testWithReporter } from "./test/test.js"; import { SilentReporter } from "./test/reporters/silent-reporter.js"; import { findChangelogEntry } from "../helpers/find-changelog-entry.js"; @@ -234,7 +235,7 @@ export async function publish( let toBackendDep = (dep: Dependency): DependencyV2 => { return { ...dep, - version: dep.version || "", + version: stripRangePrefix(dep.version || ""), repo: dep.repo || "", }; }; diff --git a/cli/commands/update.ts b/cli/commands/update.ts index 13db78e4..3c7f3763 100644 --- a/cli/commands/update.ts +++ b/cli/commands/update.ts @@ -69,32 +69,21 @@ export async function update(pkg?: string, { lock }: UpdateOptions = {}) { console.log(chalk.green("All dependencies are up to date!")); } } else { - for (let dep of available) { - let devDeps = Object.keys(config["dev-dependencies"] || {}); - let allDeps = [...Object.keys(config.dependencies || {}), ...devDeps]; + let devDepKeys = Object.keys(config["dev-dependencies"] || {}); + let allDepKeys = [...Object.keys(config.dependencies || {}), ...devDepKeys]; - let dev = false; + for (let dep of available) { let bareOld = stripRangePrefix(dep[1]); - for (let d of devDeps) { + let matchesName = (d: string) => { let pinnedVersion = getDepPinnedVersion(d); - if ( + return ( getDepName(d) === dep[0] && (!pinnedVersion || bareOld.startsWith(pinnedVersion)) - ) { - dev = true; - break; - } - } - - let asName = - allDeps.find((d) => { - let pinnedVersion = getDepPinnedVersion(d); - return ( - getDepName(d) === dep[0] && - (!pinnedVersion || bareOld.startsWith(pinnedVersion)) - ); - }) || dep[0]; + ); + }; + let dev = devDepKeys.some(matchesName); + let asName = allDepKeys.find(matchesName) || dep[0]; let rangePrefix = isRange(dep[1]) ? dep[1][0] : ""; await add(`${dep[0]}@${rangePrefix}${dep[2]}`, { dev, lock }, asName); } diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index da51e1e2..93a3199f 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -33,7 +33,9 @@ function resolveRangeFromCache( ): string { let key = `${name}@${version}`; let cached = cache.get(key); - if (cached !== undefined) return cached; + if (cached !== undefined) { + return cached; + } let bareVersion = stripRangePrefix(version); if (!isRange(version)) { @@ -222,7 +224,6 @@ export async function resolvePackages({ hasConflicts = true; } - // Check range constraint satisfaction let resolved = packages[dep]; if (resolved?.version) { let resolvedExact = resolveRangeFromCache( diff --git a/cli/semver.ts b/cli/semver.ts index 25cda323..c7537197 100644 --- a/cli/semver.ts +++ b/cli/semver.ts @@ -17,22 +17,33 @@ export function stripRangePrefix(spec: string): string { /** * Map a range spec to the SemverPart that getHighestSemverBatch expects. * - * ^1.2.3 (major>0) → #major (highest within same major) - * ^0.2.3 (minor>0) → #minor (highest within same minor) - * ^0.0.3 → #patch (highest within same patch = exact) - * ~X.Y.Z → #minor (highest within same minor) + * Backend semantics: + * #major → any higher version (no constraint) + * #minor → highest within same major + * #patch → highest within same major.minor + * + * Mapping: + * ^1.2.3 (major>0) → #minor (same major) + * ^0.2.3 (minor>0) → #patch (same major.minor) + * ^0.0.3 → null (exact pin, caller must handle) + * ~X.Y.Z → #patch (same major.minor) */ -export function rangeToSemverPart(spec: string): SemverPart { +export function rangeToSemverPart(spec: string): SemverPart | null { let bare = stripRangePrefix(spec); let parsed = semver.parse(bare); - if (!parsed) return { major: null }; + if (!parsed) { + return { minor: null }; + } if (spec.startsWith("~")) { - return { minor: null }; + return { patch: null }; } - // caret - if (parsed.major !== 0) return { major: null }; - if (parsed.minor !== 0) return { minor: null }; - return { patch: null }; + if (parsed.major !== 0) { + return { minor: null }; + } + if (parsed.minor !== 0) { + return { patch: null }; + } + return null; } diff --git a/cli/tests/semver.test.ts b/cli/tests/semver.test.ts index f3069631..fb5575cd 100644 --- a/cli/tests/semver.test.ts +++ b/cli/tests/semver.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "@jest/globals"; -import { semver, isRange, stripRangePrefix, rangeToSemverPart } from "../semver"; +import { + semver, + isRange, + stripRangePrefix, + rangeToSemverPart, +} from "../semver"; describe("isRange", () => { test("caret is range", () => expect(isRange("^1.0.0")).toBe(true)); @@ -45,24 +50,28 @@ describe("semver re-export works for version ranges", () => { }); describe("rangeToSemverPart", () => { - test("caret major>0 maps to #major", () => { - expect(rangeToSemverPart("^1.2.3")).toEqual({ major: null }); - expect(rangeToSemverPart("^2.0.0")).toEqual({ major: null }); + test("caret major>0 maps to #minor (same major)", () => { + expect(rangeToSemverPart("^1.2.3")).toEqual({ minor: null }); + expect(rangeToSemverPart("^2.0.0")).toEqual({ minor: null }); }); - test("caret 0.minor>0 maps to #minor", () => { - expect(rangeToSemverPart("^0.2.3")).toEqual({ minor: null }); - expect(rangeToSemverPart("^0.1.0")).toEqual({ minor: null }); + test("caret 0.minor>0 maps to #patch (same major.minor)", () => { + expect(rangeToSemverPart("^0.2.3")).toEqual({ patch: null }); + expect(rangeToSemverPart("^0.1.0")).toEqual({ patch: null }); }); - test("caret 0.0.z maps to #patch", () => { - expect(rangeToSemverPart("^0.0.3")).toEqual({ patch: null }); - expect(rangeToSemverPart("^0.0.0")).toEqual({ patch: null }); + test("caret 0.0.z returns null (exact pin)", () => { + expect(rangeToSemverPart("^0.0.3")).toBeNull(); + expect(rangeToSemverPart("^0.0.0")).toBeNull(); }); - test("tilde always maps to #minor", () => { - expect(rangeToSemverPart("~1.2.3")).toEqual({ minor: null }); - expect(rangeToSemverPart("~0.2.3")).toEqual({ minor: null }); - expect(rangeToSemverPart("~0.0.3")).toEqual({ minor: null }); + test("tilde maps to #patch (same major.minor)", () => { + expect(rangeToSemverPart("~1.2.3")).toEqual({ patch: null }); + expect(rangeToSemverPart("~0.2.3")).toEqual({ patch: null }); + expect(rangeToSemverPart("~0.0.3")).toEqual({ patch: null }); + }); + + test("invalid version falls back to #minor", () => { + expect(rangeToSemverPart("^not-a-version")).toEqual({ minor: null }); }); }); From 927d42d612909c147d001c1dc150db8606a2c1b9 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Wed, 15 Apr 2026 14:19:14 +0200 Subject: [PATCH 7/8] chore: revert semver.mo typo fix to avoid canister redeploy Made-with: Cursor --- backend/main/utils/semver.mo | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/main/utils/semver.mo b/backend/main/utils/semver.mo index 54bfdf7b..eb386ff1 100644 --- a/backend/main/utils/semver.mo +++ b/backend/main/utils/semver.mo @@ -73,7 +73,7 @@ module { var prevChar = '.'; var index = 0; var digitSeq = 0; - let wrongFormatErr = #err("invalid version: wrong version format '" # ver # "'. Expected version format is 'xx.xx.xx'"); + let wrongFormatErr = #err("invalid version: wrong version fromat '" # ver # "'. Expected version format is 'xx.xx.xx'"); for (char in ver.chars()) { let unexpectedCharErr = #err("invalid version: unexpected char '" # Char.toText(char) # "' in '" # ver # "' at index " # Nat.toText(index)); From c51b2c8d2b95ec9d36488e66d1451dc7171a7980 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Thu, 23 Apr 2026 11:48:02 +0200 Subject: [PATCH 8/8] refactor: simplify version range resolution and address review findings - Drop module-level cache state in cli/cache.ts; replace findCachedVersions + resetCachedDirEntries with stateless listCachedPackages - Inline range resolution in resolvePackages; comparison uses stripRangePrefix + semver.compare directly (no cache lookup, no non-null assertion) - Run resolver's full graph walk when conflicts != "ignore" so the range-satisfaction check actually fires after mops add/update - mops add core@^x.y.z now passes the range to installMopsDep (was installing the floor instead of highest matching) - rangeToSemverPart returns null on invalid input instead of silently defaulting to {minor: null} - Skip per-constraint check when major-mismatch was already reported - Warn (not reject) on publish with ranged deps; older CLI versions cannot install such packages - Restore unrelated comments to reduce churn Made-with: Cursor --- cli/CHANGELOG.md | 3 +- cli/cache.ts | 20 +---- cli/commands/add.ts | 10 +-- cli/commands/available-updates.ts | 4 - cli/commands/publish.ts | 18 ++++- cli/commands/update.ts | 12 +-- cli/resolve-packages.ts | 126 +++++++++++++----------------- cli/semver.ts | 15 ++-- cli/tests/semver.test.ts | 4 +- 9 files changed, 92 insertions(+), 120 deletions(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index ab933234..0e478111 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -5,7 +5,8 @@ - `mops add` now defaults to caret range (e.g. `mops add core` writes `core = "^1.2.3"`) - `mops add core@1.2.3` still pins an exact version - `mops update` preserves range type when bumping versions -- Resolver validates that all range constraints are satisfied and reports errors on conflicts +- Resolver verifies that the resolved version satisfies all transitive range constraints, and reports a warning/error otherwise +- `mops publish` warns when publishing a package with version ranges in dependencies (older mops CLI versions cannot install such packages) ## 2.12.3 - Fix `mops install --lock update` silently no-op'ing on a corrupt lockfile (#515) diff --git a/cli/cache.ts b/cli/cache.ts index 06c9d4e9..55cc2048 100644 --- a/cli/cache.ts +++ b/cli/cache.ts @@ -77,23 +77,9 @@ export let copyCache = (cacheName: string, dest: string) => { }); }; -let cachedDirEntries: string[] | null = null; - -export function resetCachedDirEntries() { - cachedDirEntries = null; -} - -export function findCachedVersions(name: string): string[] { - if (!cachedDirEntries) { - let packagesDir = path.join(getGlobalCacheDir(), "packages"); - cachedDirEntries = fs.existsSync(packagesDir) - ? fs.readdirSync(packagesDir) - : []; - } - let prefix = name + "@"; - return cachedDirEntries - .filter((entry) => entry.startsWith(prefix)) - .map((entry) => entry.slice(prefix.length)); +export function listCachedPackages(): string[] { + let packagesDir = path.join(getGlobalCacheDir(), "packages"); + return fs.existsSync(packagesDir) ? fs.readdirSync(packagesDir) : []; } export let cacheSize = async () => { diff --git a/cli/commands/add.ts b/cli/commands/add.ts index 10d09999..deda32a5 100644 --- a/cli/commands/add.ts +++ b/cli/commands/add.ts @@ -17,7 +17,6 @@ import { checkRequirements } from "../check-requirements.js"; import { syncLocalCache } from "./install/sync-local-cache.js"; import { notifyInstalls } from "../notify-installs.js"; import { resolvePackages } from "../resolve-packages.js"; -import { stripRangePrefix } from "../semver.js"; type AddOptions = { verbose?: boolean; @@ -109,11 +108,9 @@ export async function add( process.exit(1); } } else if (!pkgDetails.path) { - let res = await installMopsDep( - pkgDetails.name, - stripRangePrefix(pkgDetails.version), - { verbose }, - ); + let res = await installMopsDep(pkgDetails.name, pkgDetails.version, { + verbose: verbose, + }); if (res === false) { return; } @@ -148,5 +145,6 @@ export async function add( `${pkgDetails.name} = "${pkgDetails.repo || pkgDetails.path || pkgDetails.version}"`, ); + // check conflicts await resolvePackages({ conflicts: "warning" }); } diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index 7123e9b3..d5012d6c 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -24,10 +24,6 @@ export async function getAvailableUpdates( getDepPinnedVersion(dep.name).split(".").length !== 3, ); - if (depsToUpdate.length === 0) { - return []; - } - let getCurrentVersion = (pkg: string, updateVersion: string) => { for (let dep of allDeps) { if (getDepName(dep.name) === pkg && dep.version) { diff --git a/cli/commands/publish.ts b/cli/commands/publish.ts index caecbab0..acda3b65 100644 --- a/cli/commands/publish.ts +++ b/cli/commands/publish.ts @@ -24,7 +24,7 @@ import { Requirement, } from "../declarations/main/main.did.js"; import { Dependency } from "../types.js"; -import { stripRangePrefix } from "../semver.js"; +import { isRange } from "../semver.js"; import { testWithReporter } from "./test/test.js"; import { SilentReporter } from "./test/reporters/silent-reporter.js"; import { findChangelogEntry } from "../helpers/find-changelog-entry.js"; @@ -164,6 +164,20 @@ export async function publish( } } + let rangeDeps = [ + ...Object.values(config.dependencies || {}), + ...Object.values(config["dev-dependencies"] || {}), + ].filter((dep) => isRange(dep.version || "")); + if (rangeDeps.length > 0) { + console.log( + chalk.yellow("Warning: ") + + "version ranges in published dependencies are only understood by recent mops CLI versions. Older clients will fail to install this package.", + ); + for (let dep of rangeDeps) { + console.log(` ${dep.name} = "${dep.version}"`); + } + } + if (config.package.keywords) { for (let keyword of config.package.keywords) { if (keyword.length > 20) { @@ -187,7 +201,7 @@ export async function publish( let toBackendDep = (dep: Dependency): DependencyV2 => { return { ...dep, - version: stripRangePrefix(dep.version || ""), + version: dep.version || "", repo: dep.repo || "", }; }; diff --git a/cli/commands/update.ts b/cli/commands/update.ts index 3c7f3763..27968ef5 100644 --- a/cli/commands/update.ts +++ b/cli/commands/update.ts @@ -72,20 +72,20 @@ export async function update(pkg?: string, { lock }: UpdateOptions = {}) { let devDepKeys = Object.keys(config["dev-dependencies"] || {}); let allDepKeys = [...Object.keys(config.dependencies || {}), ...devDepKeys]; - for (let dep of available) { - let bareOld = stripRangePrefix(dep[1]); + for (let [name, oldVersion, newVersion] of available) { + let bareOld = stripRangePrefix(oldVersion); let matchesName = (d: string) => { let pinnedVersion = getDepPinnedVersion(d); return ( - getDepName(d) === dep[0] && + getDepName(d) === name && (!pinnedVersion || bareOld.startsWith(pinnedVersion)) ); }; let dev = devDepKeys.some(matchesName); - let asName = allDepKeys.find(matchesName) || dep[0]; - let rangePrefix = isRange(dep[1]) ? dep[1][0] : ""; - await add(`${dep[0]}@${rangePrefix}${dep[2]}`, { dev, lock }, asName); + let asName = allDepKeys.find(matchesName) || name; + let rangePrefix = isRange(oldVersion) ? oldVersion[0] : ""; + await add(`${name}@${rangePrefix}${newVersion}`, { dev, lock }, asName); } } diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index 93a3199f..b2f536cd 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -11,44 +11,14 @@ import { import { VesselConfig, readVesselConfig } from "./vessel.js"; import { Config, Dependency } from "./types.js"; import { - findCachedVersions, getDepCacheDir, getDepCacheName, - resetCachedDirEntries, + listCachedPackages, } from "./cache.js"; import { getPackageId } from "./helpers/get-package-id.js"; import { checkLockFileLight, readLockFile } from "./integrity.js"; import { semver, isRange, stripRangePrefix } from "./semver.js"; -type VersionConstraint = { - isMopsPackage: boolean; - version: string; - dependencyOf: string; -}; - -function resolveRangeFromCache( - name: string, - version: string, - cache: Map, -): string { - let key = `${name}@${version}`; - let cached = cache.get(key); - if (cached !== undefined) { - return cached; - } - - let bareVersion = stripRangePrefix(version); - if (!isRange(version)) { - cache.set(key, bareVersion); - return bareVersion; - } - - let installed = findCachedVersions(name); - let resolved = semver.maxSatisfying(installed, version) || bareVersion; - cache.set(key, resolved); - return resolved; -} - export async function resolvePackages({ conflicts = "ignore" as "warning" | "error" | "ignore", } = {}): Promise> { @@ -56,7 +26,9 @@ export async function resolvePackages({ return {}; } - if (checkLockFileLight()) { + // skip the lock-file shortcut when the caller wants conflict detection; + // conflicts are only computed during the full graph walk below. + if (conflicts === "ignore" && checkLockFileLight()) { let lockFileJson = readLockFile(); if (lockFileJson && lockFileJson.version === 3) { @@ -64,13 +36,33 @@ export async function resolvePackages({ } } - // Invalidate cached dir listing so we pick up freshly installed packages - resetCachedDirEntries(); - let rootDir = getRootDir(); let packages: Record = {}; - let versions: Record> = {}; - let rangeCache = new Map(); + let versions: Record< + string, + Array<{ + isMopsPackage: boolean; + version: string; + dependencyOf: string; + }> + > = {}; + + // Resolve a range like "^1.2.3" by picking the highest cached version that + // satisfies it. Falls back to the floor when nothing is installed (e.g. when + // the resolver is invoked before a successful install). + let cachedEntries = listCachedPackages(); + let resolveRange = (name: string, version: string): string => { + if (!isRange(version)) { + return version; + } + let prefix = `${name}@`; + let installed = cachedEntries + .filter((e) => e.startsWith(prefix)) + .map((e) => e.slice(prefix.length)); + return ( + semver.maxSatisfying(installed, version) || stripRangePrefix(version) + ); + }; const gitVerRegex = new RegExp(/v(\d{1,2}\.\d{1,2}\.\d{1,2})(-.*)?$/); @@ -102,14 +94,7 @@ export async function resolvePackages({ for (const pkgDetails of allDeps) { const { name, repo, version } = pkgDetails; - // For version comparison, use resolved versions (not range floors) - let resolvedCurrent = version - ? resolveRangeFromCache(name, version, rangeCache) - : ""; - let resolvedExisting = packages[name]?.version - ? resolveRangeFromCache(name, packages[name]!.version || "", rangeCache) - : ""; - + // take root dep version or bigger one if ( isRoot || !packages[name] || @@ -118,8 +103,8 @@ export async function resolvePackages({ packages[name]?.repo && compareGitVersions(packages[name]?.repo || "", repo) === -1) || semver.compare( - resolvedExisting || "0.0.0", - resolvedCurrent || "0.0.0", + stripRangePrefix(packages[name]?.version || "0.0.0"), + stripRangePrefix(version || "0.0.0"), ) === -1)) ) { let temp = { @@ -128,6 +113,7 @@ export async function resolvePackages({ }; packages[name] = temp; + // normalize path relative to the root config dir if (pkgDetails.path) { temp.path = path.relative( rootDir, @@ -139,6 +125,7 @@ export async function resolvePackages({ let nestedConfig; let localNestedDir = ""; + // read nested config if (repo) { let cacheDir = getDepCacheName(name, repo); nestedConfig = @@ -154,13 +141,13 @@ export async function resolvePackages({ nestedConfig = readConfig(mopsToml); } } else if (version) { - let resolved = resolveRangeFromCache(name, version, rangeCache); - let cacheDir = getDepCacheName(name, resolved); + let cacheDir = getDepCacheName(name, resolveRange(name, version)); nestedConfig = readConfig( path.join(getDepCacheDir(cacheDir), "mops.toml"), ); } + // collect nested deps if (nestedConfig) { await collectDeps(nestedConfig, localNestedDir, false); } @@ -197,7 +184,9 @@ export async function resolvePackages({ let config = readConfig(); await collectDeps(config, rootDir, true); + // show conflicts let hasConflicts = false; + let warn = chalk.redBright(conflicts === "error" ? "Error!" : "Warning!"); if (conflicts !== "ignore") { for (let [dep, vers] of Object.entries(versions)) { @@ -208,8 +197,7 @@ export async function resolvePackages({ ); if (majors.size > 1) { console.error( - chalk.reset("") + - chalk.redBright(conflicts === "error" ? "Error!" : "Warning!"), + chalk.reset("") + warn, `Conflicting versions of dependency "${dep}"`, ); @@ -222,28 +210,22 @@ export async function resolvePackages({ } hasConflicts = true; + continue; } - let resolved = packages[dep]; - if (resolved?.version) { - let resolvedExact = resolveRangeFromCache( - dep, - resolved.version, - rangeCache, - ); - for (let constraint of mopsVers) { - if (isRange(constraint.version)) { - if (!semver.satisfies(resolvedExact, constraint.version)) { - console.error( - chalk.reset("") + - chalk.redBright( - conflicts === "error" ? "Error!" : "Warning!", - ), - `Resolved version ${dep}@${resolvedExact} does not satisfy constraint "${constraint.version}" required by ${chalk.bold(constraint.dependencyOf)}`, - ); - hasConflicts = true; - } - } + // verify resolved version satisfies all transitive range constraints + let resolved = packages[dep]?.version; + if (!resolved) { + continue; + } + let resolvedExact = resolveRange(dep, resolved); + for (let { version, dependencyOf } of mopsVers) { + if (isRange(version) && !semver.satisfies(resolvedExact, version)) { + console.error( + chalk.reset("") + warn, + `Resolved version ${dep}@${resolvedExact} does not satisfy constraint "${version}" required by ${chalk.bold(dependencyOf)}`, + ); + hasConflicts = true; } } } @@ -264,7 +246,7 @@ export async function resolvePackages({ } else if (pkg.repo) { version = pkg.repo; } else if (pkg.version) { - version = resolveRangeFromCache(name, pkg.version, rangeCache); + version = resolveRange(name, pkg.version); } else { return [name, ""]; } diff --git a/cli/semver.ts b/cli/semver.ts index c7537197..c82b2ee3 100644 --- a/cli/semver.ts +++ b/cli/semver.ts @@ -8,10 +8,7 @@ export function isRange(spec: string): boolean { } export function stripRangePrefix(spec: string): string { - if (spec.startsWith("^") || spec.startsWith("~")) { - return spec.slice(1); - } - return spec; + return isRange(spec) ? spec.slice(1) : spec; } /** @@ -25,20 +22,18 @@ export function stripRangePrefix(spec: string): string { * Mapping: * ^1.2.3 (major>0) → #minor (same major) * ^0.2.3 (minor>0) → #patch (same major.minor) - * ^0.0.3 → null (exact pin, caller must handle) + * ^0.0.3 → null (exact pin, caller must handle) * ~X.Y.Z → #patch (same major.minor) + * invalid/unparseable → null (caller must handle) */ export function rangeToSemverPart(spec: string): SemverPart | null { - let bare = stripRangePrefix(spec); - let parsed = semver.parse(bare); + let parsed = semver.parse(stripRangePrefix(spec)); if (!parsed) { - return { minor: null }; + return null; } - if (spec.startsWith("~")) { return { patch: null }; } - if (parsed.major !== 0) { return { minor: null }; } diff --git a/cli/tests/semver.test.ts b/cli/tests/semver.test.ts index fb5575cd..0792ca0b 100644 --- a/cli/tests/semver.test.ts +++ b/cli/tests/semver.test.ts @@ -71,7 +71,7 @@ describe("rangeToSemverPart", () => { expect(rangeToSemverPart("~0.0.3")).toEqual({ patch: null }); }); - test("invalid version falls back to #minor", () => { - expect(rangeToSemverPart("^not-a-version")).toEqual({ minor: null }); + test("invalid version returns null", () => { + expect(rangeToSemverPart("^not-a-version")).toBeNull(); }); });