ic-mops 2.13.0 → 2.13.2

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 (34) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/commands/check-stable.ts +27 -23
  4. package/commands/check.ts +0 -1
  5. package/commands/lint.ts +40 -7
  6. package/dist/commands/check-stable.d.ts +0 -1
  7. package/dist/commands/check-stable.js +21 -18
  8. package/dist/commands/check.js +0 -1
  9. package/dist/commands/lint.js +19 -1
  10. package/dist/helpers/migrations.d.ts +8 -1
  11. package/dist/helpers/migrations.js +60 -26
  12. package/dist/package.json +1 -1
  13. package/dist/tests/check-stable.test.js +17 -0
  14. package/dist/tests/check.test.js +4 -3
  15. package/dist/tests/helpers.js +5 -1
  16. package/dist/tests/lint.test.js +63 -1
  17. package/dist/tests/migrate.test.js +2 -2
  18. package/dist/types.d.ts +1 -0
  19. package/helpers/migrations.ts +91 -35
  20. package/package.json +1 -1
  21. package/tests/__snapshots__/migrate.test.ts.snap +1 -2
  22. package/tests/check-stable/migrations-chain/deployed.most +14 -0
  23. package/tests/check-stable/migrations-chain/migrations/20250101_000000_Init.mo +8 -0
  24. package/tests/check-stable/migrations-chain/migrations/20250201_000000_AddField.mo +9 -0
  25. package/tests/check-stable/migrations-chain/migrations/20250301_000000_AddD.mo +10 -0
  26. package/tests/check-stable/migrations-chain/migrations/20250401_000000_AddE.mo +11 -0
  27. package/tests/check-stable/migrations-chain/mops.toml +15 -0
  28. package/tests/check-stable/migrations-chain/src/main.mo +13 -0
  29. package/tests/check-stable.test.ts +20 -0
  30. package/tests/check.test.ts +4 -3
  31. package/tests/helpers.ts +5 -1
  32. package/tests/lint.test.ts +85 -1
  33. package/tests/migrate.test.ts +2 -2
  34. package/types.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Next
4
4
 
5
+ ## 2.13.2
6
+ - Fix race conditions when two `mops` processes run on the same project (e.g. an editor watcher and `caffeine check --fix`, or back-to-back invocations). `mops check-stable` used a shared `.mops/.check-stable/` scratch dir and `mops check`/`build`/`check-stable` used a shared `<parent>/.migrations-<canister>/` staging dir; concurrent runs would clobber each other and surface as misleading errors like `.mops/.check-stable/new.most: No such file or directory` or `EEXIST: file already exists, symlink ...`. Both directories are now per-invocation (created via `mkdtemp` and removed when the command finishes).
7
+ - Deprecate `skipIfMissing` in `[canisters.<name>.check-stable]`. Behavior is unchanged for now, but `mops check`/`check-stable` print a warning when it is set. For initial deployments, commit a `.most` file at the configured `path` containing an empty actor (`// Version: 1.0.0\nactor { };`) instead — the stable check then runs against an empty baseline.
8
+ - Drop the "you may need a migration" hint after a failed stable compatibility check in `mops check`/`check-stable`. The hint guessed at whether the user needed a new migration or a fix to an existing one, and `moc`'s underlying compatibility error already links to the migration docs.
9
+ - The missing-chain-directory error from `mops check`/`build`/`check-stable` now points at adding a `.mo` file to the `chain` directory instead of running the experimental `mops migrate new <Name>` command.
10
+
11
+ ## 2.13.1
12
+ - `mops lint` now honors `[canisters.<name>.migrations].check-limit`, skipping trimmed chain migrations so projects with large migration histories lint as fast as they type-check. Pass an explicit filter (`mops lint <name>`) to opt back in for a one-off lint of a trimmed file.
13
+
5
14
  ## 2.13.0
6
15
  - Fix `mops update` and `mops outdated` jumping across major versions (or pre-1.0 minor versions) — they are now caret-bound by default, matching `cargo update`. For example, `core = "2.0.0"` now updates within `2.x.y` instead of jumping to a future `3.0.0`. Use `--major` to opt into cross-major updates.
7
16
 
package/bundle/cli.tgz CHANGED
Binary file
@@ -1,5 +1,5 @@
1
- import { basename, join } from "node:path";
2
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { join } from "node:path";
2
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
3
3
  import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { execa } from "execa";
@@ -17,7 +17,10 @@ import {
17
17
  import { sourcesArgs } from "./sources.js";
18
18
  import { toolchain } from "./toolchain/index.js";
19
19
 
20
- const CHECK_STABLE_DIR = ".mops/.check-stable";
20
+ // Per-invocation scratch dir lives under `.mops/`; `mkdtempSync` makes it unique so
21
+ // concurrent `mops` processes don't clobber each other's `old.most`/`new.most`.
22
+ const CHECK_STABLE_PARENT = ".mops";
23
+ const CHECK_STABLE_PREFIX = ".check-stable-";
21
24
 
22
25
  export interface CheckStableOptions {
23
26
  verbose: boolean;
@@ -39,15 +42,25 @@ export function resolveStablePath(
39
42
  return null;
40
43
  }
41
44
  const stablePath = resolveConfigPath(stableConfig.path);
45
+ if (stableConfig.skipIfMissing) {
46
+ console.warn(
47
+ chalk.yellow(
48
+ `WARN: \`skipIfMissing\` in [canisters.${canisterName}.check-stable] is deprecated. ` +
49
+ `Instead, create ${stableConfig.path} with an empty actor:\n` +
50
+ " // Version: 1.0.0\n" +
51
+ " actor { };",
52
+ ),
53
+ );
54
+ }
42
55
  if (!existsSync(stablePath)) {
43
56
  if (stableConfig.skipIfMissing) {
44
57
  return null;
45
58
  }
46
59
  cliError(
47
60
  `Deployed file not found: ${stablePath} (canister '${canisterName}')\n` +
48
- "Set skipIfMissing = true in [canisters." +
49
- canisterName +
50
- ".check-stable] to skip this check when the file is missing.",
61
+ `Create ${stableConfig.path} with an empty actor to enable the check:\n` +
62
+ " // Version: 1.0.0\n" +
63
+ " actor { };",
51
64
  );
52
65
  }
53
66
  return stablePath;
@@ -88,7 +101,6 @@ export async function checkStable(
88
101
  globalMocArgs,
89
102
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
90
103
  options,
91
- hasMigrations: !!canister.migrations,
92
104
  });
93
105
  } finally {
94
106
  await migration.cleanup();
@@ -131,7 +143,6 @@ export async function checkStable(
131
143
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
132
144
  sources,
133
145
  options,
134
- hasMigrations: !!canister.migrations,
135
146
  });
136
147
  } finally {
137
148
  await migration.cleanup();
@@ -159,7 +170,6 @@ export interface RunStableCheckParams {
159
170
  canisterArgs: string[];
160
171
  sources?: string[];
161
172
  options?: Partial<CheckStableOptions>;
162
- hasMigrations?: boolean;
163
173
  }
164
174
 
165
175
  export async function runStableCheck(
@@ -182,15 +192,17 @@ export async function runStableCheck(
182
192
  cliError(`File not found: ${oldFile}`);
183
193
  }
184
194
 
185
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
186
- mkdirSync(CHECK_STABLE_DIR, { recursive: true });
195
+ mkdirSync(CHECK_STABLE_PARENT, { recursive: true });
196
+ const scratchDir = mkdtempSync(
197
+ join(CHECK_STABLE_PARENT, CHECK_STABLE_PREFIX),
198
+ );
187
199
  try {
188
200
  const oldMostPath = isOldMostFile
189
201
  ? oldFile
190
202
  : await generateStableTypes(
191
203
  mocPath,
192
204
  oldFile,
193
- join(CHECK_STABLE_DIR, "old.most"),
205
+ join(scratchDir, "old.most"),
194
206
  sources,
195
207
  globalMocArgs,
196
208
  canisterArgs,
@@ -200,7 +212,7 @@ export async function runStableCheck(
200
212
  const newMostPath = await generateStableTypes(
201
213
  mocPath,
202
214
  canisterMain,
203
- join(CHECK_STABLE_DIR, "new.most"),
215
+ join(scratchDir, "new.most"),
204
216
  sources,
205
217
  globalMocArgs,
206
218
  canisterArgs,
@@ -228,13 +240,6 @@ export async function runStableCheck(
228
240
  if (result.stderr) {
229
241
  console.error(result.stderr);
230
242
  }
231
- if (params.hasMigrations) {
232
- console.error(
233
- chalk.yellow(
234
- "Hint: You may need a migration. Run `mops migrate new <Name>` to create one.",
235
- ),
236
- );
237
- }
238
243
  cliError(
239
244
  `✗ Stable compatibility check failed for canister '${canisterName}'`,
240
245
  );
@@ -246,7 +251,7 @@ export async function runStableCheck(
246
251
  ),
247
252
  );
248
253
  } finally {
249
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
254
+ await rm(scratchDir, { recursive: true, force: true });
250
255
  }
251
256
  }
252
257
 
@@ -259,8 +264,7 @@ async function generateStableTypes(
259
264
  canisterArgs: string[],
260
265
  options: Partial<CheckStableOptions>,
261
266
  ): Promise<string> {
262
- const base = basename(outputPath, ".most");
263
- const wasmPath = join(CHECK_STABLE_DIR, base + ".wasm");
267
+ const wasmPath = outputPath.replace(/\.most$/, ".wasm");
264
268
  const args = [
265
269
  "--stable-types",
266
270
  "-o",
package/commands/check.ts CHANGED
@@ -216,7 +216,6 @@ async function checkCanisters(
216
216
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
217
217
  sources,
218
218
  options: { verbose: options.verbose, extraArgs: options.extraArgs },
219
- hasMigrations: !!canister.migrations,
220
219
  });
221
220
  }
222
221
  } finally {
package/commands/lint.ts CHANGED
@@ -15,6 +15,7 @@ import { toolchain } from "./toolchain/index.js";
15
15
  import { MOTOKO_GLOB_CONFIG } from "../constants.js";
16
16
  import { existsSync } from "node:fs";
17
17
  import { Config } from "../types.js";
18
+ import { getTrimmedMigrationFiles } from "../helpers/migrations.js";
18
19
 
19
20
  async function resolveDepRules(
20
21
  config: Config,
@@ -128,6 +129,17 @@ function buildCommonArgs(
128
129
  return args;
129
130
  }
130
131
 
132
+ function dropTrimmedMigrations(
133
+ files: string[],
134
+ rootDir: string,
135
+ excluded: Set<string>,
136
+ ): string[] {
137
+ if (excluded.size === 0) {
138
+ return files;
139
+ }
140
+ return files.filter((f) => !excluded.has(path.resolve(rootDir, f)));
141
+ }
142
+
131
143
  async function runLintoko(
132
144
  lintokoBinPath: string,
133
145
  rootDir: string,
@@ -169,6 +181,11 @@ export async function lint(
169
181
  ? await toolchain.bin("lintoko")
170
182
  : "lintoko";
171
183
 
184
+ const isExplicit = !!filter || !!(options.files && options.files.length > 0);
185
+ const trimmedMigrations = isExplicit
186
+ ? new Set<string>()
187
+ : getTrimmedMigrationFiles(config);
188
+
172
189
  let filesToLint: string[];
173
190
  if (options.files && options.files.length > 0) {
174
191
  filesToLint = options.files;
@@ -185,6 +202,20 @@ export async function lint(
185
202
  : "No .mo files found in the project",
186
203
  );
187
204
  }
205
+ const before = filesToLint.length;
206
+ filesToLint = dropTrimmedMigrations(
207
+ filesToLint,
208
+ rootDir,
209
+ trimmedMigrations,
210
+ );
211
+ if (options.verbose && before !== filesToLint.length) {
212
+ console.log(
213
+ chalk.blue("lint"),
214
+ chalk.gray(
215
+ `Trimmed ${before - filesToLint.length} migration file(s) (check-limit)`,
216
+ ),
217
+ );
218
+ }
188
219
  }
189
220
 
190
221
  const commonArgs = buildCommonArgs(options, config);
@@ -198,13 +229,9 @@ export async function lint(
198
229
  rules.forEach((rule) => baseArgs.push("--rules", rule));
199
230
  baseArgs.push(...filesToLint);
200
231
 
201
- let failed = !(await runLintoko(
202
- lintokoBinPath,
203
- rootDir,
204
- baseArgs,
205
- options,
206
- "base",
207
- ));
232
+ let failed =
233
+ filesToLint.length > 0 &&
234
+ !(await runLintoko(lintokoBinPath, rootDir, baseArgs, options, "base"));
208
235
 
209
236
  // --- extra runs ---
210
237
  const extraEntries = config.lint?.extra;
@@ -243,6 +270,12 @@ export async function lint(
243
270
  );
244
271
  }
245
272
 
273
+ matchedFiles = dropTrimmedMigrations(
274
+ matchedFiles,
275
+ rootDir,
276
+ trimmedMigrations,
277
+ );
278
+
246
279
  if (matchedFiles.length === 0) {
247
280
  console.warn(
248
281
  chalk.yellow(
@@ -16,6 +16,5 @@ export interface RunStableCheckParams {
16
16
  canisterArgs: string[];
17
17
  sources?: string[];
18
18
  options?: Partial<CheckStableOptions>;
19
- hasMigrations?: boolean;
20
19
  }
21
20
  export declare function runStableCheck(params: RunStableCheckParams): Promise<void>;
@@ -1,5 +1,5 @@
1
- import { basename, join } from "node:path";
2
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { join } from "node:path";
2
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
3
3
  import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { execa } from "execa";
@@ -9,7 +9,10 @@ import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js";
9
9
  import { filterCanisters, looksLikeFile, resolveCanisterConfigs, resolveSingleCanister, validateCanisterArgs, } from "../helpers/resolve-canisters.js";
10
10
  import { sourcesArgs } from "./sources.js";
11
11
  import { toolchain } from "./toolchain/index.js";
12
- const CHECK_STABLE_DIR = ".mops/.check-stable";
12
+ // Per-invocation scratch dir lives under `.mops/`; `mkdtempSync` makes it unique so
13
+ // concurrent `mops` processes don't clobber each other's `old.most`/`new.most`.
14
+ const CHECK_STABLE_PARENT = ".mops";
15
+ const CHECK_STABLE_PREFIX = ".check-stable-";
13
16
  export function resolveStablePath(canister, canisterName, options) {
14
17
  const stableConfig = canister["check-stable"];
15
18
  if (!stableConfig) {
@@ -19,14 +22,20 @@ export function resolveStablePath(canister, canisterName, options) {
19
22
  return null;
20
23
  }
21
24
  const stablePath = resolveConfigPath(stableConfig.path);
25
+ if (stableConfig.skipIfMissing) {
26
+ console.warn(chalk.yellow(`WARN: \`skipIfMissing\` in [canisters.${canisterName}.check-stable] is deprecated. ` +
27
+ `Instead, create ${stableConfig.path} with an empty actor:\n` +
28
+ " // Version: 1.0.0\n" +
29
+ " actor { };"));
30
+ }
22
31
  if (!existsSync(stablePath)) {
23
32
  if (stableConfig.skipIfMissing) {
24
33
  return null;
25
34
  }
26
35
  cliError(`Deployed file not found: ${stablePath} (canister '${canisterName}')\n` +
27
- "Set skipIfMissing = true in [canisters." +
28
- canisterName +
29
- ".check-stable] to skip this check when the file is missing.");
36
+ `Create ${stableConfig.path} with an empty actor to enable the check:\n` +
37
+ " // Version: 1.0.0\n" +
38
+ " actor { };");
30
39
  }
31
40
  return stablePath;
32
41
  }
@@ -53,7 +62,6 @@ export async function checkStable(args, options = {}) {
53
62
  globalMocArgs,
54
63
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
55
64
  options,
56
- hasMigrations: !!canister.migrations,
57
65
  });
58
66
  }
59
67
  finally {
@@ -88,7 +96,6 @@ export async function checkStable(args, options = {}) {
88
96
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
89
97
  sources,
90
98
  options,
91
- hasMigrations: !!canister.migrations,
92
99
  });
93
100
  }
94
101
  finally {
@@ -111,13 +118,13 @@ export async function runStableCheck(params) {
111
118
  if (!existsSync(oldFile)) {
112
119
  cliError(`File not found: ${oldFile}`);
113
120
  }
114
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
115
- mkdirSync(CHECK_STABLE_DIR, { recursive: true });
121
+ mkdirSync(CHECK_STABLE_PARENT, { recursive: true });
122
+ const scratchDir = mkdtempSync(join(CHECK_STABLE_PARENT, CHECK_STABLE_PREFIX));
116
123
  try {
117
124
  const oldMostPath = isOldMostFile
118
125
  ? oldFile
119
- : await generateStableTypes(mocPath, oldFile, join(CHECK_STABLE_DIR, "old.most"), sources, globalMocArgs, canisterArgs, options);
120
- const newMostPath = await generateStableTypes(mocPath, canisterMain, join(CHECK_STABLE_DIR, "new.most"), sources, globalMocArgs, canisterArgs, options);
126
+ : await generateStableTypes(mocPath, oldFile, join(scratchDir, "old.most"), sources, globalMocArgs, canisterArgs, options);
127
+ const newMostPath = await generateStableTypes(mocPath, canisterMain, join(scratchDir, "new.most"), sources, globalMocArgs, canisterArgs, options);
121
128
  if (options.verbose) {
122
129
  console.log(chalk.blue("check-stable"), chalk.gray(`Comparing ${oldMostPath} ↔ ${newMostPath}`));
123
130
  }
@@ -133,20 +140,16 @@ export async function runStableCheck(params) {
133
140
  if (result.stderr) {
134
141
  console.error(result.stderr);
135
142
  }
136
- if (params.hasMigrations) {
137
- console.error(chalk.yellow("Hint: You may need a migration. Run `mops migrate new <Name>` to create one."));
138
- }
139
143
  cliError(`✗ Stable compatibility check failed for canister '${canisterName}'`);
140
144
  }
141
145
  console.log(chalk.green(`✓ Stable compatibility check passed for canister '${canisterName}'`));
142
146
  }
143
147
  finally {
144
- await rm(CHECK_STABLE_DIR, { recursive: true, force: true });
148
+ await rm(scratchDir, { recursive: true, force: true });
145
149
  }
146
150
  }
147
151
  async function generateStableTypes(mocPath, moFile, outputPath, sources, globalMocArgs, canisterArgs, options) {
148
- const base = basename(outputPath, ".most");
149
- const wasmPath = join(CHECK_STABLE_DIR, base + ".wasm");
152
+ const wasmPath = outputPath.replace(/\.most$/, ".wasm");
150
153
  const args = [
151
154
  "--stable-types",
152
155
  "-o",
@@ -137,7 +137,6 @@ async function checkCanisters(config, canisters, options) {
137
137
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
138
138
  sources,
139
139
  options: { verbose: options.verbose, extraArgs: options.extraArgs },
140
- hasMigrations: !!canister.migrations,
141
140
  });
142
141
  }
143
142
  }
@@ -8,6 +8,7 @@ import { resolvePackages } from "../resolve-packages.js";
8
8
  import { toolchain } from "./toolchain/index.js";
9
9
  import { MOTOKO_GLOB_CONFIG } from "../constants.js";
10
10
  import { existsSync } from "node:fs";
11
+ import { getTrimmedMigrationFiles } from "../helpers/migrations.js";
11
12
  async function resolveDepRules(config, rootDir) {
12
13
  const ext = config.lint?.extends;
13
14
  if (!ext) {
@@ -83,6 +84,12 @@ function buildCommonArgs(options, config) {
83
84
  }
84
85
  return args;
85
86
  }
87
+ function dropTrimmedMigrations(files, rootDir, excluded) {
88
+ if (excluded.size === 0) {
89
+ return files;
90
+ }
91
+ return files.filter((f) => !excluded.has(path.resolve(rootDir, f)));
92
+ }
86
93
  async function runLintoko(lintokoBinPath, rootDir, args, options, label) {
87
94
  try {
88
95
  if (options.verbose) {
@@ -107,6 +114,10 @@ export async function lint(filter, options) {
107
114
  let lintokoBinPath = config.toolchain?.lintoko
108
115
  ? await toolchain.bin("lintoko")
109
116
  : "lintoko";
117
+ const isExplicit = !!filter || !!(options.files && options.files.length > 0);
118
+ const trimmedMigrations = isExplicit
119
+ ? new Set()
120
+ : getTrimmedMigrationFiles(config);
110
121
  let filesToLint;
111
122
  if (options.files && options.files.length > 0) {
112
123
  filesToLint = options.files;
@@ -122,6 +133,11 @@ export async function lint(filter, options) {
122
133
  ? `No files found for filter '${filter}'`
123
134
  : "No .mo files found in the project");
124
135
  }
136
+ const before = filesToLint.length;
137
+ filesToLint = dropTrimmedMigrations(filesToLint, rootDir, trimmedMigrations);
138
+ if (options.verbose && before !== filesToLint.length) {
139
+ console.log(chalk.blue("lint"), chalk.gray(`Trimmed ${before - filesToLint.length} migration file(s) (check-limit)`));
140
+ }
125
141
  }
126
142
  const commonArgs = buildCommonArgs(options, config);
127
143
  // --- base run ---
@@ -131,7 +147,8 @@ export async function lint(filter, options) {
131
147
  : await collectLintRules(config, rootDir);
132
148
  rules.forEach((rule) => baseArgs.push("--rules", rule));
133
149
  baseArgs.push(...filesToLint);
134
- let failed = !(await runLintoko(lintokoBinPath, rootDir, baseArgs, options, "base"));
150
+ let failed = filesToLint.length > 0 &&
151
+ !(await runLintoko(lintokoBinPath, rootDir, baseArgs, options, "base"));
135
152
  // --- extra runs ---
136
153
  const extraEntries = config.lint?.extra;
137
154
  if (extraEntries) {
@@ -156,6 +173,7 @@ export async function lint(filter, options) {
156
173
  if (baseFileSet) {
157
174
  matchedFiles = matchedFiles.filter((f) => baseFileSet.has(path.resolve(rootDir, f)));
158
175
  }
176
+ matchedFiles = dropTrimmedMigrations(matchedFiles, rootDir, trimmedMigrations);
159
177
  if (matchedFiles.length === 0) {
160
178
  console.warn(chalk.yellow(`[lint.extra] no files matched glob '${globPattern}', skipping`));
161
179
  continue;
@@ -1,4 +1,4 @@
1
- import { MigrationsConfig } from "../types.js";
1
+ import { Config, MigrationsConfig } from "../types.js";
2
2
  export interface MigrationArgsResult {
3
3
  migrationArgs: string[];
4
4
  cleanup: () => Promise<void>;
@@ -8,3 +8,10 @@ export declare function getNextMigrationFile(nextDir: string): string | null;
8
8
  export declare function validateNextMigrationOrder(chainDirOrFiles: string | string[], nextFile: string): void;
9
9
  export declare function validateMigrationsConfig(migrations: MigrationsConfig, canisterName: string): void;
10
10
  export declare function prepareMigrationArgs(migrations: MigrationsConfig | undefined, canisterName: string, mode: "check" | "build", verbose?: boolean): Promise<MigrationArgsResult>;
11
+ /**
12
+ * Absolute paths of chain migration files that `mops lint` should skip,
13
+ * mirroring the `check-limit` trimming applied to `moc` during `mops check`.
14
+ * Validates the migrations config along the way, so misconfig surfaces here
15
+ * just as it does in `mops check` (consistent failure across commands).
16
+ */
17
+ export declare function getTrimmedMigrationFiles(config: Config): Set<string>;
@@ -1,9 +1,10 @@
1
- import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { cliError } from "../error.js";
6
6
  import { getRootDir, resolveConfigPath } from "../mops.js";
7
+ import { resolveCanisterConfigs } from "./resolve-canisters.js";
7
8
  function stagedMigrationsDir(chainDir, canisterName) {
8
9
  return join(dirname(chainDir), `.migrations-${canisterName}`);
9
10
  }
@@ -60,14 +61,14 @@ export function validateMigrationsConfig(migrations, canisterName) {
60
61
  }
61
62
  }
62
63
  }
63
- export async function prepareMigrationArgs(migrations, canisterName, mode, verbose) {
64
- const noOp = {
65
- migrationArgs: [],
66
- cleanup: async () => { },
67
- };
68
- if (!migrations) {
69
- return noOp;
70
- }
64
+ /**
65
+ * Resolve the active migration chain for a canister: validate config, discover
66
+ * files, and apply `check-limit` / `build-limit`. Single source of truth for
67
+ * the trim semantics shared by `prepareMigrationArgs` (which stages `included`
68
+ * for moc) and `getTrimmedMigrationFiles` (which feeds `excludedChainFiles`
69
+ * to lint).
70
+ */
71
+ function resolveMigrationChain(migrations, canisterName, mode) {
71
72
  validateMigrationsConfig(migrations, canisterName);
72
73
  const chainDir = resolveConfigPath(migrations.chain);
73
74
  const nextDir = migrations.next
@@ -76,50 +77,64 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
76
77
  const nextFile = nextDir ? getNextMigrationFile(nextDir) : null;
77
78
  if (!existsSync(chainDir) && !nextFile) {
78
79
  cliError(`Migration chain directory not found: ${chainDir}\n` +
79
- "Run `mops migrate new <Name>` to initialize the migration chain.");
80
+ "Create the directory and add a `.mo` migration file to initialize the chain.");
80
81
  }
81
82
  const chainFiles = getMigrationFiles(chainDir);
82
83
  if (nextFile) {
83
84
  validateNextMigrationOrder(chainFiles, nextFile);
84
85
  }
85
- const allMigrations = chainFiles.map((f) => ({
86
+ // Treat chain + next as one virtual merged list; `next` is always last.
87
+ const all = chainFiles.map((f) => ({
86
88
  file: f,
87
89
  dir: chainDir,
88
90
  }));
89
91
  if (nextFile && nextDir) {
90
- allMigrations.push({ file: nextFile, dir: nextDir });
92
+ all.push({ file: nextFile, dir: nextDir });
91
93
  }
92
94
  const limit = mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
93
- const isTrimming = limit !== undefined && limit < allMigrations.length;
94
- const needsTempDir = nextFile !== null || isTrimming;
95
+ const isTrimming = limit !== undefined && limit < all.length;
96
+ const included = isTrimming ? all.slice(-limit) : all;
97
+ // Dropped entries are always a chain-only prefix (next sorts last).
98
+ const excludedChainFiles = all
99
+ .slice(0, all.length - included.length)
100
+ .map((e) => resolve(e.dir, e.file));
101
+ return { chainDir, nextDir, included, excludedChainFiles, isTrimming };
102
+ }
103
+ export async function prepareMigrationArgs(migrations, canisterName, mode, verbose) {
104
+ if (!migrations) {
105
+ return { migrationArgs: [], cleanup: async () => { } };
106
+ }
107
+ const { chainDir, nextDir, included, excludedChainFiles, isTrimming } = resolveMigrationChain(migrations, canisterName, mode);
108
+ const hasNext = included.some((e) => e.dir === nextDir);
109
+ const needsTempDir = hasNext || isTrimming;
95
110
  if (!needsTempDir) {
96
111
  return {
97
112
  migrationArgs: [`--enhanced-migration=${chainDir}`],
98
113
  cleanup: async () => { },
99
114
  };
100
115
  }
101
- // Shortcut: when only the pending next migration is needed (empty chain or
102
- // trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
103
- if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
116
+ // Shortcut: only the pending next migration is included point moc at
117
+ // next-migration/ so diagnostics use the real path instead of the temp dir.
118
+ if (nextDir && included.length === 1 && included[0].dir === nextDir) {
104
119
  const migrationArgs = [`--enhanced-migration=${nextDir}`];
105
120
  if (isTrimming) {
106
121
  migrationArgs.push("-A=M0254");
107
122
  }
108
123
  return { migrationArgs, cleanup: async () => { } };
109
124
  }
110
- const tempDir = stagedMigrationsDir(chainDir, canisterName);
111
- await rm(tempDir, { recursive: true, force: true });
112
- mkdirSync(tempDir, { recursive: true });
125
+ // Per-invocation staging dir; `mkdtempSync` makes it unique so concurrent `mops`
126
+ // processes don't clobber each other's symlinks. Cleaned up below in `cleanup()`.
127
+ const baseDir = stagedMigrationsDir(chainDir, canisterName);
128
+ mkdirSync(dirname(baseDir), { recursive: true });
129
+ const tempDir = mkdtempSync(`${baseDir}-`);
113
130
  writeFileSync(join(tempDir, ".gitignore"), "*\n");
114
- const filesToInclude = isTrimming
115
- ? allMigrations.slice(-limit)
116
- : allMigrations;
117
- for (const { file, dir } of filesToInclude) {
131
+ for (const { file, dir } of included) {
118
132
  symlinkSync(resolve(dir, file), join(tempDir, file));
119
133
  }
120
134
  if (verbose) {
121
- console.log(chalk.blue("migrations"), chalk.gray(`Prepared ${filesToInclude.length} migration(s) for ${canisterName}` +
122
- (isTrimming ? ` (trimmed from ${allMigrations.length})` : "")));
135
+ const totalCount = included.length + excludedChainFiles.length;
136
+ console.log(chalk.blue("migrations"), chalk.gray(`Prepared ${included.length} migration(s) for ${canisterName}` +
137
+ (isTrimming ? ` (trimmed from ${totalCount})` : "")));
123
138
  }
124
139
  const migrationArgs = [`--enhanced-migration=${tempDir}`];
125
140
  if (isTrimming) {
@@ -132,3 +147,22 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
132
147
  },
133
148
  };
134
149
  }
150
+ /**
151
+ * Absolute paths of chain migration files that `mops lint` should skip,
152
+ * mirroring the `check-limit` trimming applied to `moc` during `mops check`.
153
+ * Validates the migrations config along the way, so misconfig surfaces here
154
+ * just as it does in `mops check` (consistent failure across commands).
155
+ */
156
+ export function getTrimmedMigrationFiles(config) {
157
+ const excluded = new Set();
158
+ for (const [name, canister] of Object.entries(resolveCanisterConfigs(config))) {
159
+ if (!canister.migrations) {
160
+ continue;
161
+ }
162
+ const { excludedChainFiles } = resolveMigrationChain(canister.migrations, name, "check");
163
+ for (const f of excludedChainFiles) {
164
+ excluded.add(f);
165
+ }
166
+ }
167
+ return excluded;
168
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.13.0",
3
+ "version": "2.13.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -77,4 +77,21 @@ describe("check-stable", () => {
77
77
  expect(result.exitCode).toBe(1);
78
78
  expect(result.stderr).toMatch(/File not found/);
79
79
  });
80
+ // Regression: two concurrent `mops check-stable` runs on the same project used to clobber
81
+ // each other's `.mops/.check-stable/new.most` and the staged migration symlinks, surfacing
82
+ // as a misleading `new.most: No such file or directory` or an `EEXIST: symlink` crash.
83
+ test("concurrent runs do not clobber each other's scratch state", async () => {
84
+ const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
85
+ const results = await Promise.all(Array.from({ length: 10 }, () => cli(["check-stable"], { cwd })));
86
+ for (const result of results) {
87
+ expect({
88
+ exitCode: result.exitCode,
89
+ stderr: result.stderr,
90
+ }).toEqual({
91
+ exitCode: 0,
92
+ stderr: "",
93
+ });
94
+ expect(result.stdout).toMatch(/Stable compatibility check passed/);
95
+ }
96
+ }, 60_000);
80
97
  });
@@ -86,18 +86,19 @@ describe("check", () => {
86
86
  expect(result.exitCode).toBe(0);
87
87
  expect(result.stdout).toMatch(/Stable compatibility check passed/);
88
88
  });
89
- test("deployed: silently skips when file missing and skipIfMissing", async () => {
89
+ test("deployed: skips when file missing and skipIfMissing, with deprecation warning", async () => {
90
90
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-skip");
91
91
  const result = await cli(["check"], { cwd });
92
92
  expect(result.exitCode).toBe(0);
93
93
  expect(result.stdout).not.toMatch(/stable/i);
94
+ expect(result.stderr).toMatch(/skipIfMissing.*deprecated/);
94
95
  });
95
- test("deployed: errors when file missing without deployedSkipIfFileMissing", async () => {
96
+ test("deployed: errors when file missing", async () => {
96
97
  const cwd = path.join(import.meta.dirname, "check/deployed-missing-error");
97
98
  const result = await cli(["check"], { cwd });
98
99
  expect(result.exitCode).toBe(1);
99
100
  expect(result.stderr).toMatch(/Deployed file not found/);
100
- expect(result.stderr).toMatch(/skipIfMissing/);
101
+ expect(result.stderr).toMatch(/empty actor/);
101
102
  });
102
103
  test("--fix runs stable check after fixing", async () => {
103
104
  const cwd = path.join(import.meta.dirname, "check/deployed-compatible");
@@ -26,7 +26,11 @@ export const normalizePaths = (text) => {
26
26
  .replace(/\/[^\s"]+\/\.cache\/mops/g, "<CACHE>")
27
27
  .replace(/\/[^\s"]+\/Library\/Caches\/mops/g, "<CACHE>")
28
28
  .replace(/\/[^\s"[\]]+\/moc(?:-wrapper)?(?=\s|$)/g, "moc-wrapper")
29
- .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper"));
29
+ .replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
30
+ // Per-invocation scratch / staging dirs use mkdtemp; redact the random suffix
31
+ // (Node's exact suffix format isn't a stable contract) so snapshots stay stable.
32
+ .replace(/\.mops\/\.check-stable-\w+/g, ".mops/.check-stable")
33
+ .replace(/(\.migrations-[\w.-]+?)-\w+(?=[/\s"]|$)/g, "$1"));
30
34
  };
31
35
  export const cliSnapshot = async (args, options, exitCode) => {
32
36
  const result = await cli(args, options);
@@ -1,4 +1,6 @@
1
- import { describe, expect, test } from "@jest/globals";
1
+ import { describe, expect, test, afterEach } from "@jest/globals";
2
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
2
4
  import path from "path";
3
5
  import { cli, cliSnapshot } from "./helpers";
4
6
  describe("lint", () => {
@@ -71,4 +73,64 @@ describe("lint", () => {
71
73
  await cliSnapshot(["lint"], { cwd }, 1);
72
74
  });
73
75
  });
76
+ describe("migration trimming via check-limit", () => {
77
+ const migrateFixturesDir = path.join(import.meta.dirname, "migrate");
78
+ const tempDirs = [];
79
+ afterEach(async () => {
80
+ for (const dir of tempDirs) {
81
+ await rm(dir, { recursive: true, force: true });
82
+ }
83
+ tempDirs.length = 0;
84
+ });
85
+ async function makeWithNextLintFixture(checkLimit) {
86
+ const dest = path.join(migrateFixturesDir, `_tmp_lint_with-next_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
87
+ await cp(path.join(migrateFixturesDir, "with-next"), dest, {
88
+ recursive: true,
89
+ });
90
+ tempDirs.push(dest);
91
+ // Empty lints/ → collectLintRules picks it up so lintoko runs cleanly
92
+ // (no rules → no violations → exit 0), preventing assertions from
93
+ // passing by coincidence on an unrelated lintoko failure.
94
+ await mkdir(path.join(dest, "lints"), { recursive: true });
95
+ let toml = readFileSync(path.join(dest, "mops.toml"), "utf-8").replace('moc = "1.5.0"', 'moc = "1.5.0"\nlintoko = "0.7.0"');
96
+ if (checkLimit !== undefined) {
97
+ toml = toml.replace('next = "next-migration"', `next = "next-migration"\ncheck-limit = ${checkLimit}`);
98
+ }
99
+ await writeFile(path.join(dest, "mops.toml"), toml);
100
+ return dest;
101
+ }
102
+ test("check-limit=1 trims old chain migrations from lint", async () => {
103
+ // with-next has 3 chain files + 1 next file. check-limit=1 keeps only
104
+ // the next file → 3 chain files trimmed from lint.
105
+ const cwd = await makeWithNextLintFixture(1);
106
+ const result = await cli(["lint", "--verbose"], { cwd });
107
+ expect(result.exitCode).toBe(0);
108
+ expect(result.stdout).toMatch(/Trimmed 3 migration file\(s\) \(check-limit\)/);
109
+ expect(result.stdout).not.toMatch(/20250101_000000_Init\.mo/);
110
+ expect(result.stdout).not.toMatch(/20250201_000000_AddName\.mo/);
111
+ expect(result.stdout).not.toMatch(/20250301_000000_AddEmail\.mo/);
112
+ expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
113
+ });
114
+ test("no check-limit → all migration files are linted", async () => {
115
+ const cwd = await makeWithNextLintFixture();
116
+ const result = await cli(["lint", "--verbose"], { cwd });
117
+ expect(result.exitCode).toBe(0);
118
+ expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
119
+ expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
120
+ expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
121
+ });
122
+ test("explicit filter bypasses trimming so user can target a chain file", async () => {
123
+ const cwd = await makeWithNextLintFixture(1);
124
+ const result = await cli(["lint", "Init", "--verbose"], { cwd });
125
+ expect(result.exitCode).toBe(0);
126
+ expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
127
+ expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
128
+ });
129
+ test("invalid check-limit fails `mops lint` (consistent with `mops check`)", async () => {
130
+ const cwd = await makeWithNextLintFixture(0);
131
+ const result = await cli(["lint"], { cwd });
132
+ expect(result.exitCode).toBe(1);
133
+ expect(result.stderr).toMatch(/check-limit must be a positive integer/);
134
+ });
135
+ });
74
136
  });
@@ -173,8 +173,8 @@ describe("migrate", () => {
173
173
  expect(most).toMatchSnapshot();
174
174
  });
175
175
  });
176
- describe("stable check hint", () => {
177
- test("stable check fails with hint when deployed.most is incompatible", async () => {
176
+ describe("stable check", () => {
177
+ test("stable check fails when deployed.most is incompatible", async () => {
178
178
  const cwd = await makeTempFixture("basic");
179
179
  await writeFile(path.join(cwd, "deployed.most"), "// Version: 1.0.0\nactor {\n stable var a : Nat;\n stable var name : Int\n};\n");
180
180
  await cliSnapshot(["check"], { cwd }, 1);
package/dist/types.d.ts CHANGED
@@ -47,6 +47,7 @@ export type CanisterConfig = {
47
47
  initArg?: string;
48
48
  "check-stable"?: {
49
49
  path: string;
50
+ /** @deprecated Create the file with an empty actor instead. */
50
51
  skipIfMissing?: boolean;
51
52
  };
52
53
  migrations?: MigrationsConfig;
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  existsSync,
3
3
  mkdirSync,
4
+ mkdtempSync,
4
5
  readdirSync,
5
6
  symlinkSync,
6
7
  writeFileSync,
@@ -10,7 +11,8 @@ import { rm } from "node:fs/promises";
10
11
  import chalk from "chalk";
11
12
  import { cliError } from "../error.js";
12
13
  import { getRootDir, resolveConfigPath } from "../mops.js";
13
- import { MigrationsConfig } from "../types.js";
14
+ import { resolveCanisterConfigs } from "./resolve-canisters.js";
15
+ import { Config, MigrationsConfig } from "../types.js";
14
16
 
15
17
  function stagedMigrationsDir(chainDir: string, canisterName: string): string {
16
18
  return join(dirname(chainDir), `.migrations-${canisterName}`);
@@ -95,21 +97,29 @@ export function validateMigrationsConfig(
95
97
  }
96
98
  }
97
99
 
98
- export async function prepareMigrationArgs(
99
- migrations: MigrationsConfig | undefined,
100
+ interface MigrationChain {
101
+ chainDir: string;
102
+ nextDir?: string;
103
+ /** Entries to pass to moc, in order, after `*-limit` trimming. */
104
+ included: { file: string; dir: string }[];
105
+ /** Absolute paths of chain files dropped by trimming (next is never dropped). */
106
+ excludedChainFiles: string[];
107
+ /** True when `*-limit` excluded any entries. */
108
+ isTrimming: boolean;
109
+ }
110
+
111
+ /**
112
+ * Resolve the active migration chain for a canister: validate config, discover
113
+ * files, and apply `check-limit` / `build-limit`. Single source of truth for
114
+ * the trim semantics shared by `prepareMigrationArgs` (which stages `included`
115
+ * for moc) and `getTrimmedMigrationFiles` (which feeds `excludedChainFiles`
116
+ * to lint).
117
+ */
118
+ function resolveMigrationChain(
119
+ migrations: MigrationsConfig,
100
120
  canisterName: string,
101
121
  mode: "check" | "build",
102
- verbose?: boolean,
103
- ): Promise<MigrationArgsResult> {
104
- const noOp: MigrationArgsResult = {
105
- migrationArgs: [],
106
- cleanup: async () => {},
107
- };
108
-
109
- if (!migrations) {
110
- return noOp;
111
- }
112
-
122
+ ): MigrationChain {
113
123
  validateMigrationsConfig(migrations, canisterName);
114
124
 
115
125
  const chainDir = resolveConfigPath(migrations.chain);
@@ -121,30 +131,51 @@ export async function prepareMigrationArgs(
121
131
  if (!existsSync(chainDir) && !nextFile) {
122
132
  cliError(
123
133
  `Migration chain directory not found: ${chainDir}\n` +
124
- "Run `mops migrate new <Name>` to initialize the migration chain.",
134
+ "Create the directory and add a `.mo` migration file to initialize the chain.",
125
135
  );
126
136
  }
127
137
 
128
138
  const chainFiles = getMigrationFiles(chainDir);
129
-
130
139
  if (nextFile) {
131
140
  validateNextMigrationOrder(chainFiles, nextFile);
132
141
  }
133
142
 
134
- // Treat chain + next as one virtual merged list
135
- type MigrationEntry = { file: string; dir: string };
136
- const allMigrations: MigrationEntry[] = chainFiles.map((f) => ({
143
+ // Treat chain + next as one virtual merged list; `next` is always last.
144
+ const all: { file: string; dir: string }[] = chainFiles.map((f) => ({
137
145
  file: f,
138
146
  dir: chainDir,
139
147
  }));
140
148
  if (nextFile && nextDir) {
141
- allMigrations.push({ file: nextFile, dir: nextDir });
149
+ all.push({ file: nextFile, dir: nextDir });
142
150
  }
143
151
 
144
152
  const limit =
145
153
  mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
146
- const isTrimming = limit !== undefined && limit < allMigrations.length;
147
- const needsTempDir = nextFile !== null || isTrimming;
154
+ const isTrimming = limit !== undefined && limit < all.length;
155
+ const included = isTrimming ? all.slice(-limit!) : all;
156
+ // Dropped entries are always a chain-only prefix (next sorts last).
157
+ const excludedChainFiles = all
158
+ .slice(0, all.length - included.length)
159
+ .map((e) => resolve(e.dir, e.file));
160
+
161
+ return { chainDir, nextDir, included, excludedChainFiles, isTrimming };
162
+ }
163
+
164
+ export async function prepareMigrationArgs(
165
+ migrations: MigrationsConfig | undefined,
166
+ canisterName: string,
167
+ mode: "check" | "build",
168
+ verbose?: boolean,
169
+ ): Promise<MigrationArgsResult> {
170
+ if (!migrations) {
171
+ return { migrationArgs: [], cleanup: async () => {} };
172
+ }
173
+
174
+ const { chainDir, nextDir, included, excludedChainFiles, isTrimming } =
175
+ resolveMigrationChain(migrations, canisterName, mode);
176
+
177
+ const hasNext = included.some((e) => e.dir === nextDir);
178
+ const needsTempDir = hasNext || isTrimming;
148
179
 
149
180
  if (!needsTempDir) {
150
181
  return {
@@ -153,9 +184,9 @@ export async function prepareMigrationArgs(
153
184
  };
154
185
  }
155
186
 
156
- // Shortcut: when only the pending next migration is needed (empty chain or
157
- // trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
158
- if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
187
+ // Shortcut: only the pending next migration is included point moc at
188
+ // next-migration/ so diagnostics use the real path instead of the temp dir.
189
+ if (nextDir && included.length === 1 && included[0]!.dir === nextDir) {
159
190
  const migrationArgs = [`--enhanced-migration=${nextDir}`];
160
191
  if (isTrimming) {
161
192
  migrationArgs.push("-A=M0254");
@@ -163,25 +194,24 @@ export async function prepareMigrationArgs(
163
194
  return { migrationArgs, cleanup: async () => {} };
164
195
  }
165
196
 
166
- const tempDir = stagedMigrationsDir(chainDir, canisterName);
167
- await rm(tempDir, { recursive: true, force: true });
168
- mkdirSync(tempDir, { recursive: true });
197
+ // Per-invocation staging dir; `mkdtempSync` makes it unique so concurrent `mops`
198
+ // processes don't clobber each other's symlinks. Cleaned up below in `cleanup()`.
199
+ const baseDir = stagedMigrationsDir(chainDir, canisterName);
200
+ mkdirSync(dirname(baseDir), { recursive: true });
201
+ const tempDir = mkdtempSync(`${baseDir}-`);
169
202
  writeFileSync(join(tempDir, ".gitignore"), "*\n");
170
203
 
171
- const filesToInclude = isTrimming
172
- ? allMigrations.slice(-limit)
173
- : allMigrations;
174
-
175
- for (const { file, dir } of filesToInclude) {
204
+ for (const { file, dir } of included) {
176
205
  symlinkSync(resolve(dir, file), join(tempDir, file));
177
206
  }
178
207
 
179
208
  if (verbose) {
209
+ const totalCount = included.length + excludedChainFiles.length;
180
210
  console.log(
181
211
  chalk.blue("migrations"),
182
212
  chalk.gray(
183
- `Prepared ${filesToInclude.length} migration(s) for ${canisterName}` +
184
- (isTrimming ? ` (trimmed from ${allMigrations.length})` : ""),
213
+ `Prepared ${included.length} migration(s) for ${canisterName}` +
214
+ (isTrimming ? ` (trimmed from ${totalCount})` : ""),
185
215
  ),
186
216
  );
187
217
  }
@@ -198,3 +228,29 @@ export async function prepareMigrationArgs(
198
228
  },
199
229
  };
200
230
  }
231
+
232
+ /**
233
+ * Absolute paths of chain migration files that `mops lint` should skip,
234
+ * mirroring the `check-limit` trimming applied to `moc` during `mops check`.
235
+ * Validates the migrations config along the way, so misconfig surfaces here
236
+ * just as it does in `mops check` (consistent failure across commands).
237
+ */
238
+ export function getTrimmedMigrationFiles(config: Config): Set<string> {
239
+ const excluded = new Set<string>();
240
+ for (const [name, canister] of Object.entries(
241
+ resolveCanisterConfigs(config),
242
+ )) {
243
+ if (!canister.migrations) {
244
+ continue;
245
+ }
246
+ const { excludedChainFiles } = resolveMigrationChain(
247
+ canister.migrations,
248
+ name,
249
+ "check",
250
+ );
251
+ for (const f of excludedChainFiles) {
252
+ excluded.add(f);
253
+ }
254
+ }
255
+ return excluded;
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.13.0",
3
+ "version": "2.13.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
@@ -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
  }
@@ -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/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
 
@@ -1,4 +1,6 @@
1
- import { describe, expect, test } from "@jest/globals";
1
+ import { describe, expect, test, afterEach } from "@jest/globals";
2
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
2
4
  import path from "path";
3
5
  import { cli, cliSnapshot } from "./helpers";
4
6
 
@@ -86,4 +88,86 @@ describe("lint", () => {
86
88
  await cliSnapshot(["lint"], { cwd }, 1);
87
89
  });
88
90
  });
91
+
92
+ describe("migration trimming via check-limit", () => {
93
+ const migrateFixturesDir = path.join(import.meta.dirname, "migrate");
94
+ const tempDirs: string[] = [];
95
+
96
+ afterEach(async () => {
97
+ for (const dir of tempDirs) {
98
+ await rm(dir, { recursive: true, force: true });
99
+ }
100
+ tempDirs.length = 0;
101
+ });
102
+
103
+ async function makeWithNextLintFixture(
104
+ checkLimit?: number,
105
+ ): Promise<string> {
106
+ const dest = path.join(
107
+ migrateFixturesDir,
108
+ `_tmp_lint_with-next_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
109
+ );
110
+ await cp(path.join(migrateFixturesDir, "with-next"), dest, {
111
+ recursive: true,
112
+ });
113
+ tempDirs.push(dest);
114
+
115
+ // Empty lints/ → collectLintRules picks it up so lintoko runs cleanly
116
+ // (no rules → no violations → exit 0), preventing assertions from
117
+ // passing by coincidence on an unrelated lintoko failure.
118
+ await mkdir(path.join(dest, "lints"), { recursive: true });
119
+
120
+ let toml = readFileSync(path.join(dest, "mops.toml"), "utf-8").replace(
121
+ 'moc = "1.5.0"',
122
+ 'moc = "1.5.0"\nlintoko = "0.7.0"',
123
+ );
124
+ if (checkLimit !== undefined) {
125
+ toml = toml.replace(
126
+ 'next = "next-migration"',
127
+ `next = "next-migration"\ncheck-limit = ${checkLimit}`,
128
+ );
129
+ }
130
+ await writeFile(path.join(dest, "mops.toml"), toml);
131
+ return dest;
132
+ }
133
+
134
+ test("check-limit=1 trims old chain migrations from lint", async () => {
135
+ // with-next has 3 chain files + 1 next file. check-limit=1 keeps only
136
+ // the next file → 3 chain files trimmed from lint.
137
+ const cwd = await makeWithNextLintFixture(1);
138
+ const result = await cli(["lint", "--verbose"], { cwd });
139
+ expect(result.exitCode).toBe(0);
140
+ expect(result.stdout).toMatch(
141
+ /Trimmed 3 migration file\(s\) \(check-limit\)/,
142
+ );
143
+ expect(result.stdout).not.toMatch(/20250101_000000_Init\.mo/);
144
+ expect(result.stdout).not.toMatch(/20250201_000000_AddName\.mo/);
145
+ expect(result.stdout).not.toMatch(/20250301_000000_AddEmail\.mo/);
146
+ expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
147
+ });
148
+
149
+ test("no check-limit → all migration files are linted", async () => {
150
+ const cwd = await makeWithNextLintFixture();
151
+ const result = await cli(["lint", "--verbose"], { cwd });
152
+ expect(result.exitCode).toBe(0);
153
+ expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
154
+ expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
155
+ expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
156
+ });
157
+
158
+ test("explicit filter bypasses trimming so user can target a chain file", async () => {
159
+ const cwd = await makeWithNextLintFixture(1);
160
+ const result = await cli(["lint", "Init", "--verbose"], { cwd });
161
+ expect(result.exitCode).toBe(0);
162
+ expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
163
+ expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
164
+ });
165
+
166
+ test("invalid check-limit fails `mops lint` (consistent with `mops check`)", async () => {
167
+ const cwd = await makeWithNextLintFixture(0);
168
+ const result = await cli(["lint"], { cwd });
169
+ expect(result.exitCode).toBe(1);
170
+ expect(result.stderr).toMatch(/check-limit must be a positive integer/);
171
+ });
172
+ });
89
173
  });
@@ -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;