ic-mops 2.13.1 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bun.lock +24 -12
  3. package/bundle/cli.tgz +0 -0
  4. package/cache.ts +101 -20
  5. package/cli.ts +40 -14
  6. package/commands/available-updates.ts +4 -2
  7. package/commands/bench.ts +3 -0
  8. package/commands/check-stable.ts +27 -23
  9. package/commands/check.ts +11 -1
  10. package/commands/init.ts +15 -2
  11. package/commands/install/install-mops-dep.ts +14 -9
  12. package/commands/install/sync-local-cache.ts +7 -1
  13. package/commands/lint.ts +11 -0
  14. package/commands/outdated.ts +5 -2
  15. package/commands/publish.ts +0 -1
  16. package/commands/test/test.ts +14 -0
  17. package/commands/update.ts +3 -2
  18. package/commands/watch/tester.ts +5 -1
  19. package/dist/cache.d.ts +3 -0
  20. package/dist/cache.js +98 -18
  21. package/dist/cli.js +16 -12
  22. package/dist/commands/available-updates.d.ts +1 -1
  23. package/dist/commands/available-updates.js +4 -1
  24. package/dist/commands/bench.js +2 -0
  25. package/dist/commands/check-stable.d.ts +0 -1
  26. package/dist/commands/check-stable.js +21 -18
  27. package/dist/commands/check.js +7 -1
  28. package/dist/commands/init.js +9 -2
  29. package/dist/commands/install/install-mops-dep.js +12 -9
  30. package/dist/commands/install/sync-local-cache.js +2 -1
  31. package/dist/commands/lint.js +7 -0
  32. package/dist/commands/outdated.d.ts +2 -1
  33. package/dist/commands/outdated.js +2 -2
  34. package/dist/commands/publish.js +0 -1
  35. package/dist/commands/test/test.d.ts +1 -1
  36. package/dist/commands/test/test.js +10 -5
  37. package/dist/commands/update.d.ts +2 -1
  38. package/dist/commands/update.js +2 -2
  39. package/dist/commands/watch/tester.js +4 -1
  40. package/dist/helpers/autofix-motoko.d.ts +1 -1
  41. package/dist/helpers/autofix-motoko.js +3 -0
  42. package/dist/helpers/deprecate-dfx-replica.d.ts +2 -0
  43. package/dist/helpers/deprecate-dfx-replica.js +20 -0
  44. package/dist/helpers/fix-lock.d.ts +1 -0
  45. package/dist/helpers/fix-lock.js +93 -0
  46. package/dist/helpers/migrations.js +7 -5
  47. package/dist/integrity.d.ts +2 -2
  48. package/dist/integrity.js +22 -6
  49. package/dist/mops.js +1 -1
  50. package/dist/package.json +3 -3
  51. package/dist/tests/check-fix.test.js +40 -0
  52. package/dist/tests/check-stable.test.js +17 -0
  53. package/dist/tests/check.test.js +4 -3
  54. package/dist/tests/cli.test.js +136 -1
  55. package/dist/tests/helpers.js +5 -1
  56. package/dist/tests/migrate.test.js +2 -2
  57. package/dist/types.d.ts +1 -0
  58. package/dist/vessel.js +21 -13
  59. package/dist/wasm/pkg/nodejs/package.json +1 -1
  60. package/dist/wasm/pkg/nodejs/wasm_bg.wasm +0 -0
  61. package/dist/wasm/pkg/web/package.json +1 -1
  62. package/dist/wasm/pkg/web/wasm_bg.wasm +0 -0
  63. package/helpers/autofix-motoko.ts +4 -1
  64. package/helpers/deprecate-dfx-replica.ts +32 -0
  65. package/helpers/fix-lock.ts +101 -0
  66. package/helpers/migrations.ts +7 -4
  67. package/integrity.ts +30 -9
  68. package/mops.ts +1 -1
  69. package/package.json +3 -3
  70. package/tests/__snapshots__/migrate.test.ts.snap +1 -2
  71. package/tests/check-fix.test.ts +46 -0
  72. package/tests/check-stable/migrations-chain/deployed.most +14 -0
  73. package/tests/check-stable/migrations-chain/migrations/20250101_000000_Init.mo +8 -0
  74. package/tests/check-stable/migrations-chain/migrations/20250201_000000_AddField.mo +9 -0
  75. package/tests/check-stable/migrations-chain/migrations/20250301_000000_AddD.mo +10 -0
  76. package/tests/check-stable/migrations-chain/migrations/20250401_000000_AddE.mo +11 -0
  77. package/tests/check-stable/migrations-chain/mops.toml +15 -0
  78. package/tests/check-stable/migrations-chain/src/main.mo +13 -0
  79. package/tests/check-stable.test.ts +20 -0
  80. package/tests/check.test.ts +4 -3
  81. package/tests/cli.test.ts +180 -1
  82. package/tests/helpers.ts +5 -1
  83. package/tests/install/update-bound-patch/mops.toml +2 -0
  84. package/tests/migrate.test.ts +2 -2
  85. package/types.ts +1 -0
  86. package/vessel.ts +31 -14
  87. package/wasm/pkg/nodejs/package.json +1 -1
  88. package/wasm/pkg/nodejs/wasm_bg.wasm +0 -0
  89. package/wasm/pkg/web/package.json +1 -1
  90. package/wasm/pkg/web/wasm_bg.wasm +0 -0
package/integrity.ts CHANGED
@@ -41,8 +41,8 @@ export async function checkIntegrity(lock?: "check" | "update" | "ignore") {
41
41
  }
42
42
 
43
43
  if (lock === "update") {
44
- await updateLockFile({ force });
45
- await checkLockFile(force);
44
+ let regenerated = await updateLockFile({ force });
45
+ await checkLockFile(force, regenerated);
46
46
  } else if (lock === "check") {
47
47
  await checkLockFile(force);
48
48
  }
@@ -159,14 +159,16 @@ export function checkLockFileLight(): boolean {
159
159
  return false;
160
160
  }
161
161
 
162
+ // returns true if the lock file was (re)written, false if it was skipped
163
+ // because the existing lock is still valid.
162
164
  export async function updateLockFile({
163
165
  force = false,
164
- }: { force?: boolean } = {}) {
166
+ }: { force?: boolean } = {}): Promise<boolean> {
165
167
  // if lock file exists and mops.toml hasn't changed, don't update it
166
168
  // (unless forced: `--lock update` must unconditionally regenerate so users
167
169
  // can recover from a corrupt lockfile without `rm mops.lock`)
168
170
  if (!force && checkLockFileLight()) {
169
- return;
171
+ return false;
170
172
  }
171
173
 
172
174
  let resolvedDeps = await resolvePackages();
@@ -201,10 +203,13 @@ export async function updateLockFile({
201
203
  console.log(" Applications: commit this file.");
202
204
  console.log(" Libraries: add mops.lock to .gitignore.");
203
205
  }
206
+ return true;
204
207
  }
205
208
 
206
209
  // compare hashes of local files with hashes from the lock file
207
- export async function checkLockFile(force = false) {
210
+ // `regenerated` indicates the lockfile was just rewritten from the registry
211
+ // (via `updateLockFile`), so any remaining hash mismatch must be a local edit.
212
+ export async function checkLockFile(force = false, regenerated = false) {
208
213
  let supportedVersions = [1, 2, 3];
209
214
  let rootDir = getRootDir();
210
215
  let lockFile = path.join(rootDir, "mops.lock");
@@ -318,10 +323,26 @@ export async function checkLockFile(force = false) {
318
323
  console.error(`Locked hash: ${lockedHash}`);
319
324
  console.error(`Actual hash: ${localHash}`);
320
325
  console.error("");
321
- console.error(
322
- "If you have not modified files under .mops/, your lockfile may be stale or corrupt.",
323
- );
324
- console.error("Run `mops install --lock update` to regenerate it.");
326
+ if (regenerated) {
327
+ // The lock was just rewritten from the registry, so the only way
328
+ // for a per-file hash to still differ is that .mops/<file> was
329
+ // edited locally. Point users at the actual fix.
330
+ let pkgDir = fileId.split("/")[0];
331
+ console.error(
332
+ `.mops/${fileId} differs from the registry — your local copy has been modified.`,
333
+ );
334
+ console.error(
335
+ `To restore from the global cache, delete the \`.mops/${pkgDir}\` directory and run \`mops install\`.`,
336
+ );
337
+ console.error(
338
+ "To keep custom changes, use a `repo` or `path` entry in mops.toml instead of editing .mops/ directly.",
339
+ );
340
+ } else {
341
+ console.error(
342
+ "If you have not modified files under .mops/, your lockfile may be stale or corrupt.",
343
+ );
344
+ console.error("Run `mops install --lock update` to regenerate it.");
345
+ }
325
346
  process.exit(1);
326
347
  }
327
348
  }
package/mops.ts CHANGED
@@ -2,7 +2,7 @@ import process from "node:process";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import { Identity } from "@icp-sdk/core/agent";
5
- import TOML from "@iarna/toml";
5
+ import TOML from "smol-toml";
6
6
  import chalk from "chalk";
7
7
  import prompts from "prompts";
8
8
  import { decodeFile } from "./pem.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.13.1",
3
+ "version": "2.14.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
@@ -50,7 +50,7 @@
50
50
  "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest"
51
51
  },
52
52
  "dependencies": {
53
- "@iarna/toml": "2.2.5",
53
+ "smol-toml": "1.6.1",
54
54
  "@icp-sdk/core": "4.0.2",
55
55
  "@noble/hashes": "1.8.0",
56
56
  "as-table": "1.0.55",
@@ -114,6 +114,6 @@
114
114
  "ts-jest": "29.4.5",
115
115
  "tsx": "4.19.2",
116
116
  "typescript": "5.9.2",
117
- "wasm-pack": "0.13.1"
117
+ "wasm-pack": "0.15.0"
118
118
  }
119
119
  }
@@ -143,12 +143,11 @@ exports[`migrate migrate new creates a migration file with timestamp and templat
143
143
  }
144
144
  `;
145
145
 
146
- exports[`migrate stable check hint stable check fails with hint when deployed.most is incompatible 1`] = `
146
+ exports[`migrate stable check stable check fails when deployed.most is incompatible 1`] = `
147
147
  {
148
148
  "exitCode": 1,
149
149
  "stderr": "(unknown location): Compatibility error [M0169], the stable variable \`a\` of the previous version cannot be implicitly discarded. The variable can only be dropped by an explicit migration function, please see https://internetcomputer.org/docs/motoko/fundamentals/actors/compatibility#explicit-migration-using-a-migration-function
150
150
  (unknown location): Compatibility error [M0169], the stable variable \`name\` of the previous version cannot be implicitly discarded. The variable can only be dropped by an explicit migration function, please see https://internetcomputer.org/docs/motoko/fundamentals/actors/compatibility#explicit-migration-using-a-migration-function
151
- Hint: You may need a migration. Run \`mops migrate new <Name>\` to create one.
152
151
  ✗ Stable compatibility check failed for canister 'backend'",
153
152
  "stdout": "✓ backend",
154
153
  }
@@ -1,6 +1,8 @@
1
1
  import { beforeAll, describe, expect, test } from "@jest/globals";
2
2
  import { cpSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
3
+ import { mkdir, writeFile } from "node:fs/promises";
3
4
  import path from "path";
5
+ import { lock } from "proper-lockfile";
4
6
  import { parseDiagnostics } from "../helpers/autofix-motoko";
5
7
  import { cli, normalizePaths } from "./helpers";
6
8
 
@@ -60,6 +62,13 @@ describe("check --fix", () => {
60
62
  return runFilePath;
61
63
  }
62
64
 
65
+ test("parseDiagnostics tolerates missing moc output", () => {
66
+ // execa with reject:false yields undefined stdout when moc fails to spawn
67
+ // or is killed (e.g. OOM); parsing must degrade to no diagnostics, not throw.
68
+ expect(parseDiagnostics(undefined)).toEqual([]);
69
+ expect(parseDiagnostics("")).toEqual([]);
70
+ });
71
+
63
72
  test("M0223", async () => {
64
73
  await testCheckFix("M0223.mo", { M0223: 1 });
65
74
  });
@@ -156,4 +165,41 @@ describe("check --fix", () => {
156
165
  expect(result.stderr).toMatch(/error/i);
157
166
  expect(result.stdout).not.toMatch(/✓ run/);
158
167
  });
168
+
169
+ test("concurrent --fix runs serialize and produce the same output", async () => {
170
+ const runFilePath = copyFixture("edit-suggestions.mo");
171
+ await cli(["check", runFilePath, "--fix", "--", warningFlags], {
172
+ cwd: fixDir,
173
+ });
174
+ const expected = readFileSync(runFilePath, "utf-8");
175
+ copyFixture("edit-suggestions.mo");
176
+
177
+ // Hold the lock from the test process itself so both children
178
+ // deterministically hit ELOCKED, print "Waiting...", and queue.
179
+ const lockTarget = path.join(fixDir, ".mops", "fix.lock");
180
+ await mkdir(path.dirname(lockTarget), { recursive: true });
181
+ await writeFile(lockTarget, "", { flag: "a" });
182
+ const release = await lock(lockTarget, { stale: 30_000 });
183
+
184
+ const childA = cli(["check", runFilePath, "--fix", "--", warningFlags], {
185
+ cwd: fixDir,
186
+ });
187
+ const childB = cli(["check", runFilePath, "--fix", "--", warningFlags], {
188
+ cwd: fixDir,
189
+ });
190
+
191
+ // Hold long enough for both children to spawn (`npm run mops` → bundler →
192
+ // CLI startup) and attempt the non-blocking acquire. 5s is conservative —
193
+ // local runs land well under 2s, the headroom is purely to absorb CI jitter.
194
+ await new Promise((r) => setTimeout(r, 5000));
195
+ await release();
196
+
197
+ const [a, b] = await Promise.all([childA, childB]);
198
+ expect(a.exitCode).toBe(0);
199
+ expect(b.exitCode).toBe(0);
200
+ expect(readFileSync(runFilePath, "utf-8")).toBe(expected);
201
+ // At least one child must have hit the held lock and queued; if neither
202
+ // did, the lock didn't actually serialize anything.
203
+ expect(a.stdout + b.stdout).toContain("Waiting for another");
204
+ });
159
205
  });
@@ -0,0 +1,14 @@
1
+ // Version: 4.0.0
2
+ {
3
+ "20250101_000000_Init" : {} -> {a : Nat; b : Text};
4
+ "20250201_000000_AddField" : (old : {a : Nat; b : Text}) -> {a : Nat; b : Text; c : Bool};
5
+ "20250301_000000_AddD" : (old : {a : Nat; b : Text; c : Bool}) -> {a : Nat; b : Text; c : Bool; d : Int};
6
+ "20250401_000000_AddE" : (old : {a : Nat; b : Text; c : Bool; d : Int}) -> {a : Nat; b : Text; c : Bool; d : Int; e : Text}
7
+ }
8
+ actor {
9
+ stable a : Nat;
10
+ stable b : Text;
11
+ stable c : Bool;
12
+ stable d : Int;
13
+ stable e : Text
14
+ };
@@ -0,0 +1,8 @@
1
+ module {
2
+ public func migration(_ : {}) : { a : Nat; b : Text } {
3
+ {
4
+ a = 42;
5
+ b = "hello";
6
+ };
7
+ };
8
+ };
@@ -0,0 +1,9 @@
1
+ module {
2
+ public func migration(old : { a : Nat; b : Text }) : {
3
+ a : Nat;
4
+ b : Text;
5
+ c : Bool;
6
+ } {
7
+ { old with c = true };
8
+ };
9
+ };
@@ -0,0 +1,10 @@
1
+ module {
2
+ public func migration(old : { a : Nat; b : Text; c : Bool }) : {
3
+ a : Nat;
4
+ b : Text;
5
+ c : Bool;
6
+ d : Int;
7
+ } {
8
+ { old with d = 0 };
9
+ };
10
+ };
@@ -0,0 +1,11 @@
1
+ module {
2
+ public func migration(old : { a : Nat; b : Text; c : Bool; d : Int }) : {
3
+ a : Nat;
4
+ b : Text;
5
+ c : Bool;
6
+ d : Int;
7
+ e : Text;
8
+ } {
9
+ { old with e = "" };
10
+ };
11
+ };
@@ -0,0 +1,15 @@
1
+ [toolchain]
2
+ moc = "1.5.0"
3
+
4
+ [moc]
5
+ args = ["--default-persistent-actors"]
6
+
7
+ [canisters.backend]
8
+ main = "src/main.mo"
9
+
10
+ [canisters.backend.migrations]
11
+ chain = "migrations"
12
+ check-limit = 3
13
+
14
+ [canisters.backend.check-stable]
15
+ path = "deployed.most"
@@ -0,0 +1,13 @@
1
+ import Prim "mo:prim";
2
+
3
+ actor {
4
+ let a : Nat;
5
+ let b : Text;
6
+ let c : Bool;
7
+ let d : Int;
8
+ let e : Text;
9
+
10
+ public func check() : async () {
11
+ Prim.debugPrint(debug_show { a; b; c; d; e });
12
+ };
13
+ };
@@ -86,4 +86,24 @@ describe("check-stable", () => {
86
86
  expect(result.exitCode).toBe(1);
87
87
  expect(result.stderr).toMatch(/File not found/);
88
88
  });
89
+
90
+ // Regression: two concurrent `mops check-stable` runs on the same project used to clobber
91
+ // each other's `.mops/.check-stable/new.most` and the staged migration symlinks, surfacing
92
+ // as a misleading `new.most: No such file or directory` or an `EEXIST: symlink` crash.
93
+ test("concurrent runs do not clobber each other's scratch state", async () => {
94
+ const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
95
+ const results = await Promise.all(
96
+ Array.from({ length: 10 }, () => cli(["check-stable"], { cwd })),
97
+ );
98
+ for (const result of results) {
99
+ expect({
100
+ exitCode: result.exitCode,
101
+ stderr: result.stderr,
102
+ }).toEqual({
103
+ exitCode: 0,
104
+ stderr: "",
105
+ });
106
+ expect(result.stdout).toMatch(/Stable compatibility check passed/);
107
+ }
108
+ }, 60_000);
89
109
  });
@@ -113,19 +113,20 @@ describe("check", () => {
113
113
  expect(result.stdout).toMatch(/Stable compatibility check passed/);
114
114
  });
115
115
 
116
- test("deployed: silently skips when file missing and skipIfMissing", async () => {
116
+ test("deployed: skips when file missing and skipIfMissing, with deprecation warning", async () => {
117
117
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-skip");
118
118
  const result = await cli(["check"], { cwd });
119
119
  expect(result.exitCode).toBe(0);
120
120
  expect(result.stdout).not.toMatch(/stable/i);
121
+ expect(result.stderr).toMatch(/skipIfMissing.*deprecated/);
121
122
  });
122
123
 
123
- test("deployed: errors when file missing without deployedSkipIfFileMissing", async () => {
124
+ test("deployed: errors when file missing", async () => {
124
125
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-error");
125
126
  const result = await cli(["check"], { cwd });
126
127
  expect(result.exitCode).toBe(1);
127
128
  expect(result.stderr).toMatch(/Deployed file not found/);
128
- expect(result.stderr).toMatch(/skipIfMissing/);
129
+ expect(result.stderr).toMatch(/empty actor/);
129
130
  });
130
131
 
131
132
  test("--fix runs stable check after fixing", async () => {
package/tests/cli.test.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, jest, test } from "@jest/globals";
2
- import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
3
12
  import path from "path";
4
13
  import { cli, normalizePaths } from "./helpers";
5
14
 
@@ -128,6 +137,108 @@ describe("install", () => {
128
137
  rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
129
138
  }
130
139
  });
140
+
141
+ // Regression: when a file under `.mops/` was edited locally (an unsupported
142
+ // but common AI-agent workflow), `--lock update` would regenerate the
143
+ // lockfile from registry hashes, then fail the per-file check, and tell the
144
+ // user to "Run `mops install --lock update` to regenerate it" — the exact
145
+ // command that just failed. The post-regen message now says the local copy
146
+ // was modified and points at the actual fix.
147
+ test("--lock update flags a locally edited .mops/ file with a clear recovery hint", async () => {
148
+ const cwd = path.join(import.meta.dirname, "install/success");
149
+ const lockFile = path.join(cwd, "mops.lock");
150
+ const localDep = path.join(cwd, ".mops", "core@1.0.0", "mops.toml");
151
+ rmSync(lockFile, { force: true });
152
+ rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
153
+ try {
154
+ const first = await cli(["install"], { cwd, env: { CI: undefined } });
155
+ expect(first.exitCode).toBe(0);
156
+ expect(existsSync(localDep)).toBe(true);
157
+
158
+ writeFileSync(
159
+ localDep,
160
+ readFileSync(localDep, "utf8") + "\n# tampered\n",
161
+ );
162
+
163
+ const result = await cli(["install", "--lock", "update"], {
164
+ cwd,
165
+ env: { CI: undefined },
166
+ });
167
+ expect(result.exitCode).toBe(1);
168
+ expect(result.stderr).toMatch(
169
+ /\.mops\/core@1\.0\.0\/mops\.toml differs from the registry/,
170
+ );
171
+ expect(result.stderr).toMatch(
172
+ /delete the `\.mops\/core@1\.0\.0` directory and run `mops install`/,
173
+ );
174
+ expect(result.stderr).not.toMatch(/Run `mops install --lock update`/);
175
+ } finally {
176
+ rmSync(lockFile, { force: true });
177
+ rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
178
+ }
179
+ });
180
+
181
+ // Regression: parallel `mops install` runs against the same project used to
182
+ // race in two places — global cache writes (`.mops/<pkg>` populated mid-write)
183
+ // and local `.mops/<pkg>` copies — leaving zero-byte / partially-written
184
+ // files. We isolate the global cache via `XDG_CACHE_HOME` so the global-write
185
+ // path actually executes (cold-cache scenario).
186
+ test("parallel `mops install` produces a complete .mops tree (no zero-byte / staging dirs)", async () => {
187
+ const cwd = path.join(import.meta.dirname, "install/success");
188
+ const lockFile = path.join(cwd, "mops.lock");
189
+ const localCache = path.join(cwd, ".mops");
190
+ const xdgCache = mkdtempSync(path.join(tmpdir(), "mops-test-xdg-"));
191
+ rmSync(lockFile, { force: true });
192
+ rmSync(localCache, { recursive: true, force: true });
193
+ try {
194
+ const N = 5;
195
+ const env = { CI: undefined, XDG_CACHE_HOME: xdgCache };
196
+ const runs = await Promise.all(
197
+ Array.from({ length: N }, () => cli(["install"], { cwd, env })),
198
+ );
199
+ for (const r of runs) {
200
+ if (r.exitCode !== 0) {
201
+ throw new Error(
202
+ `mops install exited ${r.exitCode}\nstdout:\n${r.stdout}\nstderr:\n${r.stderr}`,
203
+ );
204
+ }
205
+ }
206
+
207
+ const walk = (dir: string): string[] => {
208
+ const out: string[] = [];
209
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
210
+ const p = path.join(dir, entry.name);
211
+ if (entry.isDirectory()) {
212
+ out.push(...walk(p));
213
+ } else if (entry.isFile()) {
214
+ out.push(p);
215
+ }
216
+ }
217
+ return out;
218
+ };
219
+ const files = walk(localCache);
220
+ const empties = files.filter((f) => statSync(f).size === 0);
221
+ expect(empties).toEqual([]);
222
+
223
+ const stagingLeftovers = readdirSync(localCache).filter((e) =>
224
+ e.startsWith(".staging-"),
225
+ );
226
+ expect(stagingLeftovers).toEqual([]);
227
+
228
+ const globalPkg = path.join(
229
+ xdgCache,
230
+ "mops",
231
+ "packages",
232
+ "core@1.0.0",
233
+ "mops.toml",
234
+ );
235
+ expect(existsSync(globalPkg)).toBe(true);
236
+ } finally {
237
+ rmSync(lockFile, { force: true });
238
+ rmSync(localCache, { recursive: true, force: true });
239
+ rmSync(xdgCache, { recursive: true, force: true });
240
+ }
241
+ });
131
242
  });
132
243
 
133
244
  // `mops update` and `mops outdated` default to caret-bound resolution: stay
@@ -214,3 +325,71 @@ describe("update / outdated bounds", () => {
214
325
  }
215
326
  });
216
327
  });
328
+
329
+ // `--patch` restricts updates to patch versions only, never crossing the minor
330
+ // bound. Fixture pins `core = "2.3.0"`; registry has 2.3.1 (patch) and 2.4.0,
331
+ // 2.5.0 (minor). Caret default lets minors through; --patch must not.
332
+ describe("update / outdated --patch bound", () => {
333
+ jest.setTimeout(120_000);
334
+
335
+ const cwd = path.join(import.meta.dirname, "install/update-bound-patch");
336
+ const tomlFile = path.join(cwd, "mops.toml");
337
+ const original = readFileSync(tomlFile, "utf8");
338
+
339
+ const cleanup = () => {
340
+ rmSync(path.join(cwd, "mops.lock"), { force: true });
341
+ rmSync(path.join(cwd, ".mops"), { recursive: true, force: true });
342
+ writeFileSync(tomlFile, original);
343
+ };
344
+
345
+ const coreVersion = (toml: string) =>
346
+ toml.match(/core = "(\d+\.\d+\.\d+)"/)?.[1];
347
+
348
+ test("mops update --patch restricts to patch bumps", async () => {
349
+ cleanup();
350
+ try {
351
+ await cli(["install"], { cwd, env: { CI: undefined } });
352
+ const result = await cli(["update", "--patch"], {
353
+ cwd,
354
+ env: { CI: undefined },
355
+ });
356
+ expect(result.exitCode).toBe(0);
357
+ const after = readFileSync(tomlFile, "utf8");
358
+ // Stays within 2.3.x — must not cross to 2.4.0 / 2.5.0
359
+ expect(coreVersion(after)).toMatch(/^2\.3\./);
360
+ expect(coreVersion(after)).not.toBe("2.3.0");
361
+ } finally {
362
+ cleanup();
363
+ }
364
+ });
365
+
366
+ test("mops outdated --patch reports patch-only updates", async () => {
367
+ cleanup();
368
+ try {
369
+ await cli(["install"], { cwd, env: { CI: undefined } });
370
+ const patch = normalizePaths(
371
+ (await cli(["outdated", "--patch"], { cwd, env: { CI: undefined } }))
372
+ .stdout,
373
+ );
374
+ // Only 2.3.x updates surface, never 2.4.x / 2.5.x
375
+ expect(patch).toMatch(/core 2\.3\.0 -> 2\.3\./);
376
+ expect(patch).not.toMatch(/core 2\.3\.0 -> 2\.[4-9]/);
377
+ } finally {
378
+ cleanup();
379
+ }
380
+ });
381
+
382
+ test.each([["update"], ["outdated"]])(
383
+ "mops %s rejects --major + --patch",
384
+ async (cmd) => {
385
+ const result = await cli([cmd, "--major", "--patch"], {
386
+ cwd,
387
+ env: { CI: undefined },
388
+ });
389
+ expect(result.exitCode).not.toBe(0);
390
+ expect(result.stderr).toMatch(
391
+ /option '--major' cannot be used with option '--patch'/,
392
+ );
393
+ },
394
+ );
395
+ });
package/tests/helpers.ts CHANGED
@@ -37,7 +37,11 @@ export const normalizePaths = (text: string): string => {
37
37
  .replace(/\/[^\s"]+\/\.cache\/mops/g, "<CACHE>")
38
38
  .replace(/\/[^\s"]+\/Library\/Caches\/mops/g, "<CACHE>")
39
39
  .replace(/\/[^\s"[\]]+\/moc(?:-wrapper)?(?=\s|$)/g, "moc-wrapper")
40
- .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper"),
40
+ .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
41
+ // Per-invocation scratch / staging dirs use mkdtemp; redact the random suffix
42
+ // (Node's exact suffix format isn't a stable contract) so snapshots stay stable.
43
+ .replace(/\.mops\/\.check-stable-\w+/g, ".mops/.check-stable")
44
+ .replace(/(\.migrations-[\w.-]+?)-\w+(?=[/\s"]|$)/g, "$1"),
41
45
  );
42
46
  };
43
47
 
@@ -0,0 +1,2 @@
1
+ [dependencies]
2
+ core = "2.3.0"
@@ -241,8 +241,8 @@ describe("migrate", () => {
241
241
  });
242
242
  });
243
243
 
244
- describe("stable check hint", () => {
245
- test("stable check fails with hint when deployed.most is incompatible", async () => {
244
+ describe("stable check", () => {
245
+ test("stable check fails when deployed.most is incompatible", async () => {
246
246
  const cwd = await makeTempFixture("basic");
247
247
  await writeFile(
248
248
  path.join(cwd, "deployed.most"),
package/types.ts CHANGED
@@ -49,6 +49,7 @@ export type CanisterConfig = {
49
49
  initArg?: string;
50
50
  "check-stable"?: {
51
51
  path: string;
52
+ /** @deprecated Create the file with an empty actor instead. */
52
53
  skipIfMissing?: boolean;
53
54
  };
54
55
  migrations?: MigrationsConfig;