From 1e6599d2c11f78deff2e1527b32f24a044a02681 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Fri, 24 Apr 2026 10:43:49 +0200 Subject: [PATCH 1/2] feat(cli): add [experimental] flags + compatible-resolution sketch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sketch only — both the [experimental] section and the compatible-resolution flag are explicitly unstable. Behavior may change or be removed. - Recognize [experimental] flags = [...] in mops.toml; older CLIs ignore it. - Hash flags into a separate optional experimentalHash in mops.lock so the fast-path stays untouched for projects that don't opt in, and toggling a flag invalidates the lockfile. - compatible-resolution: bare versions in the root project's [dependencies] / [dev-dependencies] resolve as Cargo-style caret ranges via the existing getHighestSemverBatch backend call. Aliased / github / local deps and transitives are unchanged. Made-with: Cursor --- cli/CHANGELOG.md | 2 + cli/experimental.ts | 8 ++++ cli/integrity.ts | 45 ++++++++++++++---- cli/resolve-packages.ts | 84 +++++++++++++++++++++++++++++++++- cli/tests/experimental.test.ts | 24 ++++++++++ cli/types.ts | 3 ++ docs/docs/09-mops.toml.md | 33 +++++++++++++ 7 files changed, 189 insertions(+), 10 deletions(-) create mode 100644 cli/experimental.ts create mode 100644 cli/tests/experimental.test.ts diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index c1198531..1a631c48 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,6 +1,8 @@ # Mops CLI Changelog ## Next +- Recognize `[experimental]` section in `mops.toml` for opt-in unstable behavior. The section accepts a `flags = [...]` array; flags listed there enable in-progress features whose behavior may change without notice. Older mops CLIs silently ignore the section. +- Add `compatible-resolution` experimental flag. When enabled, bare versions in the root project's `[dependencies]` and `[dev-dependencies]` are interpreted as caret ranges (Cargo-style): `core = "1.2.3"` resolves to the highest published `1.x.y` (or highest `0.2.x` for pre-1.0). No syntax change to `mops.toml`, no impact on published package configs, no canister changes. Lockfile interaction: the resolved (potentially upgraded) version is pinned, and an `experimentalHash` field in `mops.lock` invalidates the lockfile when flags change. Older CLIs reading a lockfile produced under this flag fail loudly with the existing per-package mismatch check. ## 2.12.3 - Fix `mops install --lock update` silently no-op'ing on a corrupt lockfile (#515) diff --git a/cli/experimental.ts b/cli/experimental.ts new file mode 100644 index 00000000..08075eef --- /dev/null +++ b/cli/experimental.ts @@ -0,0 +1,8 @@ +import { Config } from "./types.js"; + +// Experimental flags are opted into via [experimental] flags = [...] in mops.toml. +// Behavior behind a flag may change or be removed without notice. +// Concrete flags are introduced alongside the feature that uses them. +export function isExperimentEnabled(config: Config, flag: string): boolean { + return config.experimental?.flags?.includes(flag) ?? false; +} diff --git a/cli/integrity.ts b/cli/integrity.ts index 123a20e9..c6470be3 100644 --- a/cli/integrity.ts +++ b/cli/integrity.ts @@ -27,6 +27,11 @@ type LockFileV2 = { type LockFileV3 = { version: 3; mopsTomlDepsHash: string; + // hash of [experimental] flags. Only present when the user has opted into + // any experimental flag. Kept separate from mopsTomlDepsHash so the + // existing "deps unchanged" fast path stays untouched for projects that + // don't use experiments. + experimentalHash?: string; hashes: Record>; deps: Record; }; @@ -107,6 +112,16 @@ function getMopsTomlDepsHash(): string { return bytesToHex(sha256(JSON.stringify(sortedDeps))); } +// Returns "" when no [experimental] flags are set, so we can skip writing +// the field to the lockfile (and avoid touching projects that don't opt in). +function getExperimentalHash(): string { + let flags = readConfig().experimental?.flags ?? []; + if (flags.length === 0) { + return ""; + } + return bytesToHex(sha256(JSON.stringify([...flags].sort()))); +} + // compare hashes of local files with hashes from the registry export async function checkRemote() { let fileHashesFromRegistry = await getFileHashesFromRegistry(); @@ -144,17 +159,15 @@ export function readLockFile(): LockFile | null { return null; } -// check if lock file exists and integrity of mopsTomlDepsHash +// check if lock file exists and integrity of mopsTomlDepsHash + experimentalHash export function checkLockFileLight(): boolean { let existingLockFileJson = readLockFile(); - if (existingLockFileJson) { - let mopsTomlDepsHash = getMopsTomlDepsHash(); - if ( - existingLockFileJson.version === 3 && - mopsTomlDepsHash === existingLockFileJson.mopsTomlDepsHash - ) { - return true; - } + if (existingLockFileJson && existingLockFileJson.version === 3) { + let depsMatch = + getMopsTomlDepsHash() === existingLockFileJson.mopsTomlDepsHash; + let experimentalMatch = + getExperimentalHash() === (existingLockFileJson.experimentalHash ?? ""); + return depsMatch && experimentalMatch; } return false; } @@ -173,9 +186,11 @@ export async function updateLockFile({ let fileHashes = await getFileHashesFromRegistry(); + let experimentalHash = getExperimentalHash(); let lockFileJson: LockFileV3 = { version: 3, mopsTomlDepsHash: getMopsTomlDepsHash(), + ...(experimentalHash && { experimentalHash }), deps: resolvedDeps, hashes: fileHashes.reduce( (acc, [packageId, fileHashes]) => { @@ -256,6 +271,18 @@ export async function checkLockFile(force = false) { } } + // V3: check [experimental] hash + if (lockFileJson.version === 3) { + let lockedExperimentalHash = lockFileJson.experimentalHash ?? ""; + if (lockedExperimentalHash !== getExperimentalHash()) { + console.error("Integrity check failed"); + console.error("Mismatched [experimental] flags hash"); + console.error(`Locked hash: ${lockedExperimentalHash || "(none)"}`); + console.error(`Actual hash: ${getExperimentalHash() || "(none)"}`); + process.exit(1); + } + } + // V3: check locked deps (including GitHub and local packages) if (lockFileJson.version === 3) { let lockedDeps = { ...lockFileJson.deps }; diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index 1885919c..0f124dd9 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -10,9 +10,19 @@ import { } from "./mops.js"; import { VesselConfig, readVesselConfig } from "./vessel.js"; import { Config, Dependency } from "./types.js"; -import { getDepCacheDir, getDepCacheName } from "./cache.js"; +import { + getDepCacheDir, + getDepCacheName, + getMopsDepCacheName, + isDepCached, +} from "./cache.js"; import { getPackageId } from "./helpers/get-package-id.js"; +import { getDepName } from "./helpers/get-dep-name.js"; import { checkLockFileLight, readLockFile } from "./integrity.js"; +import { isExperimentEnabled } from "./experimental.js"; +import { mainActor } from "./api/actors.js"; +import { SemverPart } from "./declarations/main/main.did.js"; +import { installMopsDep } from "./commands/install/install-mops-dep.js"; export async function resolvePackages({ conflicts = "ignore" as "warning" | "error" | "ignore", @@ -178,6 +188,11 @@ export async function resolvePackages({ }; let config = readConfig(); + + if (isExperimentEnabled(config, "compatible-resolution")) { + await applyCompatibleResolution(config); + } + await collectDeps(config, rootDir, true); // show conflicts @@ -231,3 +246,70 @@ export async function resolvePackages({ .filter(([, version]) => version !== ""), ); } + +// Experiment: "compatible-resolution". +// Treat bare versions in the root project's [dependencies] / [dev-dependencies] +// as caret ranges (Cargo-style). For each eligible mops dep, query the registry +// for the highest compatible version and rewrite the in-memory config so the +// rest of the resolver uses it. +// +// Caret semantics map to backend SemverPart: +// - bare "1.2.3" -> #minor (highest within major 1) +// - bare "0.2.3" -> #patch (highest within 0.2.x; pre-1.0 caret) +// +// Aliased deps (e.g. `core@1.2.3 = "1.2.3"`) and non-mops deps (github / local) +// are passed through unchanged. Aliases can be added in a follow-up. +async function applyCompatibleResolution(config: Config): Promise { + let rootDeps: Dependency[] = [ + ...Object.values(config.dependencies || {}), + ...Object.values(config["dev-dependencies"] || {}), + ]; + let candidates = rootDeps.filter( + (dep) => + dep.version && + !dep.repo && + !dep.path && + getDepName(dep.name) === dep.name, + ); + if (candidates.length === 0) { + return; + } + + let actor = await mainActor(); + let res = await actor.getHighestSemverBatch( + candidates.map((dep) => { + let major = parseInt(dep.version!.split(".")[0] || "0"); + let part: SemverPart = major === 0 ? { patch: null } : { minor: null }; + return [dep.name, dep.version!, part]; + }), + ); + if ("err" in res) { + console.error( + chalk.red("Error:"), + `compatible-resolution failed: ${res.err}`, + ); + process.exit(1); + } + let resolved = new Map(res.ok); + for (let dep of candidates) { + let upgraded = resolved.get(dep.name); + if (!upgraded || upgraded === dep.version) { + continue; + } + // Download the upgraded version (and its transitives) before mutating + // the in-memory dep, so the subsequent collectDeps walk can read its + // mops.toml from the cache. resolvePackages is a read-mostly function, + // but the experiment unavoidably needs the upgraded package on disk. + if (!isDepCached(getMopsDepCacheName(dep.name, upgraded))) { + let ok = await installMopsDep(dep.name, upgraded, { silent: true }); + if (!ok) { + console.error( + chalk.red("Error:"), + `compatible-resolution failed to install ${dep.name}@${upgraded}`, + ); + process.exit(1); + } + } + dep.version = upgraded; + } +} diff --git a/cli/tests/experimental.test.ts b/cli/tests/experimental.test.ts new file mode 100644 index 00000000..862b5549 --- /dev/null +++ b/cli/tests/experimental.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "@jest/globals"; +import { isExperimentEnabled } from "../experimental"; +import { Config } from "../types"; + +describe("isExperimentEnabled", () => { + test("returns false when no [experimental] section", () => { + expect(isExperimentEnabled({}, "any-flag")).toBe(false); + }); + + test("returns false when flags array is empty", () => { + let config: Config = { experimental: { flags: [] } }; + expect(isExperimentEnabled(config, "any-flag")).toBe(false); + }); + + test("returns false when the flag is not listed", () => { + let config: Config = { experimental: { flags: ["other-flag"] } }; + expect(isExperimentEnabled(config, "wanted-flag")).toBe(false); + }); + + test("returns true when the flag is listed", () => { + let config: Config = { experimental: { flags: ["wanted-flag"] } }; + expect(isExperimentEnabled(config, "wanted-flag")).toBe(true); + }); +}); diff --git a/cli/types.ts b/cli/types.ts index f82ca3a8..7518eeb3 100644 --- a/cli/types.ts +++ b/cli/types.ts @@ -33,6 +33,9 @@ export type Config = { extends?: string[] | true; extra?: Record; }; + experimental?: { + flags?: string[]; + }; }; export type MigrationsConfig = { diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 530a6e66..aad7d6c7 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -244,6 +244,39 @@ Use only if your package will not work with older versions of the `moc`. | -------------------- | ------------------------------------------------ | | moc | Motoko compiler version (e.g. `0.11.0` which means `>=0.11.0`) | +## [experimental] + +Opt into in-progress CLI features. Behavior behind any flag listed here may change or be removed without notice — do not rely on it for production projects. + +| Field | Description | +| ----- | ----------- | +| flags | Array of experimental flag names to enable | + +Example: +```toml +[experimental] +flags = ["compatible-resolution"] +``` + +Unknown flags are silently ignored, so adding a flag your CLI version doesn't recognize is harmless. + +### Available flags + +#### `compatible-resolution` + +Treat bare versions in the root project's `[dependencies]` and `[dev-dependencies]` as caret ranges (Cargo-style): + +- `core = "1.2.3"` resolves to the highest published `1.x.y` where `x >= 2` (`>=1.2.3, <2.0.0`) +- `core = "0.2.3"` resolves to the highest published `0.2.x` where `x >= 3` (`>=0.2.3, <0.3.0`) — pre-1.0 caret semantics + +The `mops.toml` syntax does not change; only resolution behavior does. The resolved version is pinned in `mops.lock`. Subsequent runs use the lockfile. + +Caveats: +- Transitive dependencies (deps of your deps) keep today's resolution behavior. +- Aliased dependencies (e.g. `core@1 = "1.2.3"`) are not affected. +- Older mops CLIs reading a lockfile produced with this flag will fail with a package-version mismatch error and ask you to re-resolve. + + ## Advanced Configuration For additional configuration options including registry endpoint overrides, see [Environment Variables](/cli/environment-variables). \ No newline at end of file From 217bb2269a00c54965af187f60cc3020262f4261 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Fri, 24 Apr 2026 13:02:19 +0200 Subject: [PATCH 2/2] caret-versions: respect bound in update/outdated, add tests, fix docs - mops update / mops outdated honor the caret bound when the flag is on - prefetch upgraded versions before collectDeps reads transitive mops.toml - use semver.major() instead of manual parse - experimentalHash invalidates the lock fast path on flag toggle - 4 integration tests: install reproducibility, caret-bounded update, flag-toggle lock invalidation, no-flag major-cross control - drop incorrect "older CLIs fail loudly" claim from docs/changelog Also: replace deprecated @noble/hashes/sha256 with sha2. Made-with: Cursor --- cli/CHANGELOG.md | 2 +- cli/commands/available-updates.ts | 6 +++ cli/experimental.ts | 4 +- cli/integrity.ts | 10 ++-- cli/resolve-packages.ts | 43 +++++------------ cli/tests/cli.test.ts | 79 +++++++++++++++++++++++++++++++ cli/tests/install/caret/mops.toml | 5 ++ docs/docs/09-mops.toml.md | 19 +++++--- 8 files changed, 120 insertions(+), 48 deletions(-) create mode 100644 cli/tests/install/caret/mops.toml diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 1a631c48..419bd8e2 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -2,7 +2,7 @@ ## Next - Recognize `[experimental]` section in `mops.toml` for opt-in unstable behavior. The section accepts a `flags = [...]` array; flags listed there enable in-progress features whose behavior may change without notice. Older mops CLIs silently ignore the section. -- Add `compatible-resolution` experimental flag. When enabled, bare versions in the root project's `[dependencies]` and `[dev-dependencies]` are interpreted as caret ranges (Cargo-style): `core = "1.2.3"` resolves to the highest published `1.x.y` (or highest `0.2.x` for pre-1.0). No syntax change to `mops.toml`, no impact on published package configs, no canister changes. Lockfile interaction: the resolved (potentially upgraded) version is pinned, and an `experimentalHash` field in `mops.lock` invalidates the lockfile when flags change. Older CLIs reading a lockfile produced under this flag fail loudly with the existing per-package mismatch check. +- Add `caret-versions` experimental flag. When enabled, bare versions in the root project's `[dependencies]` and `[dev-dependencies]` resolve as Cargo-style caret ranges — `core = "1.2.3"` picks the highest `1.x.y`, `core = "0.2.3"` the highest `0.2.x`. `mops update` and `mops outdated` honor the same bound. The resolved version is pinned in `mops.lock`. ## 2.12.3 - Fix `mops install --lock update` silently no-op'ing on a corrupt lockfile (#515) diff --git a/cli/commands/available-updates.ts b/cli/commands/available-updates.ts index d4a8ae9a..5e28e065 100644 --- a/cli/commands/available-updates.ts +++ b/cli/commands/available-updates.ts @@ -1,9 +1,11 @@ import process from "node:process"; import chalk from "chalk"; +import semver from "semver"; 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 { isExperimentEnabled } from "../experimental.js"; // [pkg, oldVersion, newVersion] export async function getAvailableUpdates( @@ -35,6 +37,7 @@ export async function getAvailableUpdates( return ""; }; + let caretMode = isExperimentEnabled(config, "caret-versions"); let actor = await mainActor(); let res = await actor.getHighestSemverBatch( depsToUpdate.map((dep) => { @@ -46,6 +49,9 @@ export async function getAvailableUpdates( pinnedVersion.split(".").length === 1 ? { minor: null } : { patch: null }; + } else if (caretMode) { + let major = semver.major(dep.version || "0.0.0"); + semverPart = major === 0 ? { patch: null } : { minor: null }; } return [name, dep.version || "", semverPart]; }), diff --git a/cli/experimental.ts b/cli/experimental.ts index 08075eef..d02769c9 100644 --- a/cli/experimental.ts +++ b/cli/experimental.ts @@ -1,8 +1,6 @@ import { Config } from "./types.js"; -// Experimental flags are opted into via [experimental] flags = [...] in mops.toml. -// Behavior behind a flag may change or be removed without notice. -// Concrete flags are introduced alongside the feature that uses them. +// Opted into via [experimental] flags = [...] in mops.toml. Unstable behavior. export function isExperimentEnabled(config: Config, flag: string): boolean { return config.experimental?.flags?.includes(flag) ?? false; } diff --git a/cli/integrity.ts b/cli/integrity.ts index c6470be3..0c528623 100644 --- a/cli/integrity.ts +++ b/cli/integrity.ts @@ -1,7 +1,7 @@ import process from "node:process"; import fs from "node:fs"; import path from "node:path"; -import { sha256 } from "@noble/hashes/sha256"; +import { sha256 } from "@noble/hashes/sha2"; import { bytesToHex } from "@noble/hashes/utils"; import { getDependencyType, getRootDir, readConfig } from "./mops.js"; import { mainActor } from "./api/actors.js"; @@ -27,10 +27,7 @@ type LockFileV2 = { type LockFileV3 = { version: 3; mopsTomlDepsHash: string; - // hash of [experimental] flags. Only present when the user has opted into - // any experimental flag. Kept separate from mopsTomlDepsHash so the - // existing "deps unchanged" fast path stays untouched for projects that - // don't use experiments. + // separate from mopsTomlDepsHash so non-experimental projects stay untouched experimentalHash?: string; hashes: Record>; deps: Record; @@ -112,8 +109,7 @@ function getMopsTomlDepsHash(): string { return bytesToHex(sha256(JSON.stringify(sortedDeps))); } -// Returns "" when no [experimental] flags are set, so we can skip writing -// the field to the lockfile (and avoid touching projects that don't opt in). +// "" when no flags are set, so the field is omitted from the lockfile function getExperimentalHash(): string { let flags = readConfig().experimental?.flags ?? []; if (flags.length === 0) { diff --git a/cli/resolve-packages.ts b/cli/resolve-packages.ts index 0f124dd9..8f007fc6 100644 --- a/cli/resolve-packages.ts +++ b/cli/resolve-packages.ts @@ -2,6 +2,7 @@ import process from "node:process"; import path from "node:path"; import { existsSync } from "node:fs"; import chalk from "chalk"; +import semver from "semver"; import { checkConfigFile, getRootDir, @@ -189,8 +190,8 @@ export async function resolvePackages({ let config = readConfig(); - if (isExperimentEnabled(config, "compatible-resolution")) { - await applyCompatibleResolution(config); + if (isExperimentEnabled(config, "caret-versions")) { + await applyCaretVersions(config); } await collectDeps(config, rootDir, true); @@ -247,19 +248,11 @@ export async function resolvePackages({ ); } -// Experiment: "compatible-resolution". -// Treat bare versions in the root project's [dependencies] / [dev-dependencies] -// as caret ranges (Cargo-style). For each eligible mops dep, query the registry -// for the highest compatible version and rewrite the in-memory config so the -// rest of the resolver uses it. -// -// Caret semantics map to backend SemverPart: -// - bare "1.2.3" -> #minor (highest within major 1) -// - bare "0.2.3" -> #patch (highest within 0.2.x; pre-1.0 caret) -// -// Aliased deps (e.g. `core@1.2.3 = "1.2.3"`) and non-mops deps (github / local) -// are passed through unchanged. Aliases can be added in a follow-up. -async function applyCompatibleResolution(config: Config): Promise { +// Experiment "caret-versions": treat bare versions in the root project's +// [dependencies] / [dev-dependencies] as Cargo-style caret ranges. +// 1.x.y -> highest within major 1; 0.x.y -> highest within 0.x. Aliased +// deps and non-mops deps (github / local) are passed through unchanged. +async function applyCaretVersions(config: Config): Promise { let rootDeps: Dependency[] = [ ...Object.values(config.dependencies || {}), ...Object.values(config["dev-dependencies"] || {}), @@ -278,16 +271,13 @@ async function applyCompatibleResolution(config: Config): Promise { let actor = await mainActor(); let res = await actor.getHighestSemverBatch( candidates.map((dep) => { - let major = parseInt(dep.version!.split(".")[0] || "0"); - let part: SemverPart = major === 0 ? { patch: null } : { minor: null }; + let part: SemverPart = + semver.major(dep.version!) === 0 ? { patch: null } : { minor: null }; return [dep.name, dep.version!, part]; }), ); if ("err" in res) { - console.error( - chalk.red("Error:"), - `compatible-resolution failed: ${res.err}`, - ); + console.error(chalk.red("Error:"), `caret-versions failed: ${res.err}`); process.exit(1); } let resolved = new Map(res.ok); @@ -296,17 +286,10 @@ async function applyCompatibleResolution(config: Config): Promise { if (!upgraded || upgraded === dep.version) { continue; } - // Download the upgraded version (and its transitives) before mutating - // the in-memory dep, so the subsequent collectDeps walk can read its - // mops.toml from the cache. resolvePackages is a read-mostly function, - // but the experiment unavoidably needs the upgraded package on disk. + // collectDeps reads transitive mops.toml from the cache, so prefetch if (!isDepCached(getMopsDepCacheName(dep.name, upgraded))) { - let ok = await installMopsDep(dep.name, upgraded, { silent: true }); + let ok = await installMopsDep(dep.name, upgraded); if (!ok) { - console.error( - chalk.red("Error:"), - `compatible-resolution failed to install ${dep.name}@${upgraded}`, - ); process.exit(1); } } diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index bb9c09b8..e59336b4 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -93,6 +93,85 @@ describe("install", () => { } }); + // `caret-versions` experiment: bare versions resolve as Cargo-style caret + // ranges. Tested against `core = "1.0.0"` because `core` has 1.0.0 as the + // only 1.x release, so the caret cap is observable: it stays at 1.0.0 + // while no-flag `mops update` jumps the major to 2.x. + describe("caret-versions experiment", () => { + const cwd = path.join(import.meta.dirname, "install/caret"); + const tomlFile = path.join(cwd, "mops.toml"); + const tomlWithFlag = readFileSync(tomlFile, "utf8"); + const tomlWithoutFlag = '[dependencies]\ncore = "1.0.0"\n'; + + const cleanup = () => { + rmSync(path.join(cwd, "mops.lock"), { force: true }); + rmSync(path.join(cwd, ".mops"), { recursive: true, force: true }); + writeFileSync(tomlFile, tomlWithFlag); + }; + + test("install is reproducible: lock pinned, second install no-op", async () => { + cleanup(); + try { + const first = await cli(["install"], { cwd, env: { CI: undefined } }); + expect(first.exitCode).toBe(0); + const lock1 = readFileSync(path.join(cwd, "mops.lock"), "utf8"); + const second = await cli(["install"], { cwd, env: { CI: undefined } }); + expect(second.exitCode).toBe(0); + expect(readFileSync(path.join(cwd, "mops.lock"), "utf8")).toBe(lock1); + } finally { + cleanup(); + } + }); + + test("mops update stays within the caret bound", async () => { + cleanup(); + try { + await cli(["install"], { cwd, env: { CI: undefined } }); + const result = await cli(["update", "core"], { + cwd, + env: { CI: undefined }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/up to date/); + expect(readFileSync(tomlFile, "utf8")).toBe(tomlWithFlag); + } finally { + cleanup(); + } + }); + + test("toggling the flag invalidates the lock (CI mode fails loudly)", async () => { + cleanup(); + try { + await cli(["install"], { cwd, env: { CI: undefined } }); + writeFileSync(tomlFile, tomlWithoutFlag); + const result = await cli(["install", "--lock", "check"], { cwd }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/Mismatched \[experimental\] flags hash/); + } finally { + cleanup(); + } + }); + + test("mops update without the flag crosses the major (control)", async () => { + cleanup(); + try { + writeFileSync(tomlFile, tomlWithoutFlag); + await cli(["install"], { cwd, env: { CI: undefined } }); + const result = await cli(["update", "core"], { + cwd, + env: { CI: undefined }, + }); + expect(result.exitCode).toBe(0); + const major = readFileSync(tomlFile, "utf8").match( + /core = "(\d+)\./, + )?.[1]; + expect(parseInt(major ?? "0")).toBeGreaterThanOrEqual(2); + } finally { + cleanup(); + } + }); + }); + // Regression: `install --lock update` used to early-return if mops.toml's // deps hash was unchanged, even when the lockfile's per-file hashes were // stale/corrupt. The subsequent checkLockFile would then fail and exit 1, diff --git a/cli/tests/install/caret/mops.toml b/cli/tests/install/caret/mops.toml new file mode 100644 index 00000000..6599940e --- /dev/null +++ b/cli/tests/install/caret/mops.toml @@ -0,0 +1,5 @@ +[experimental] +flags = ["caret-versions"] + +[dependencies] +core = "1.0.0" diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index aad7d6c7..92a480db 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -246,7 +246,7 @@ Use only if your package will not work with older versions of the `moc`. ## [experimental] -Opt into in-progress CLI features. Behavior behind any flag listed here may change or be removed without notice — do not rely on it for production projects. +Opt into in-progress CLI features. Behavior behind any flag listed here may change or be removed without notice. | Field | Description | | ----- | ----------- | @@ -255,26 +255,31 @@ Opt into in-progress CLI features. Behavior behind any flag listed here may chan Example: ```toml [experimental] -flags = ["compatible-resolution"] +flags = ["caret-versions"] ``` Unknown flags are silently ignored, so adding a flag your CLI version doesn't recognize is harmless. ### Available flags -#### `compatible-resolution` +#### `caret-versions` Treat bare versions in the root project's `[dependencies]` and `[dev-dependencies]` as caret ranges (Cargo-style): -- `core = "1.2.3"` resolves to the highest published `1.x.y` where `x >= 2` (`>=1.2.3, <2.0.0`) -- `core = "0.2.3"` resolves to the highest published `0.2.x` where `x >= 3` (`>=0.2.3, <0.3.0`) — pre-1.0 caret semantics +- `core = "1.2.3"` resolves to the highest published `1.x.y` where `x.y >= 2.3` (`>=1.2.3, <2.0.0`) +- `core = "0.2.3"` resolves to the highest published `0.2.x` where `x >= 3` (`>=0.2.3, <0.3.0`) -The `mops.toml` syntax does not change; only resolution behavior does. The resolved version is pinned in `mops.lock`. Subsequent runs use the lockfile. +The `mops.toml` syntax does not change — explicit `^x.y.z` is **not** accepted. Bare versions just resolve differently. The resolved version is pinned in `mops.lock`; subsequent runs use the lockfile. + +Workflow: +- `mops install` is reproducible: it never bumps a dep on its own — newly published compatible versions are not pulled in. +- `mops install --lock update` re-resolves and writes the highest caret-compatible versions into `mops.lock` (`mops.toml` unchanged). +- `mops update [pkg]` rewrites `mops.toml` (and the lock) up to the highest caret-compatible version. Without the flag, `mops update` ignores the caret bound and can cross majors. `mops outdated` follows the same caret bound. Caveats: - Transitive dependencies (deps of your deps) keep today's resolution behavior. - Aliased dependencies (e.g. `core@1 = "1.2.3"`) are not affected. -- Older mops CLIs reading a lockfile produced with this flag will fail with a package-version mismatch error and ask you to re-resolve. +- Older mops CLIs that don't recognize this flag still read the lockfile and use the resolved (upgraded) versions transparently — they just won't re-resolve when the flag toggles. ## Advanced Configuration