ic-mops 2.15.0 → 2.15.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 (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/cli.ts +31 -11
  4. package/commands/bench-replica.ts +2 -1
  5. package/commands/bench.ts +32 -9
  6. package/commands/check-stable.ts +20 -3
  7. package/commands/check.ts +6 -1
  8. package/dist/cli.js +18 -10
  9. package/dist/commands/bench-replica.js +3 -1
  10. package/dist/commands/bench.js +21 -4
  11. package/dist/commands/check-stable.d.ts +2 -1
  12. package/dist/commands/check-stable.js +8 -2
  13. package/dist/commands/check.js +6 -1
  14. package/dist/helpers/autofix-motoko.js +19 -2
  15. package/dist/helpers/migrations.d.ts +16 -0
  16. package/dist/helpers/migrations.js +71 -1
  17. package/dist/helpers/parse-most.d.ts +5 -0
  18. package/dist/helpers/parse-most.js +28 -0
  19. package/dist/helpers/pocket-ic-client.d.ts +2 -2
  20. package/dist/helpers/pocket-ic-client.js +8 -4
  21. package/dist/package.json +1 -1
  22. package/dist/tests/check-fix.test.js +14 -2
  23. package/dist/tests/check-stable.test.js +15 -0
  24. package/dist/tests/check.test.js +5 -0
  25. package/dist/tests/lint.test.js +1 -1
  26. package/dist/tests/migrations-most.test.d.ts +1 -0
  27. package/dist/tests/migrations-most.test.js +47 -0
  28. package/helpers/autofix-motoko.ts +23 -2
  29. package/helpers/migrations.ts +117 -0
  30. package/helpers/parse-most.ts +32 -0
  31. package/helpers/pocket-ic-client.ts +11 -5
  32. package/package.json +1 -1
  33. package/tests/__snapshots__/check-stable.test.ts.snap +11 -0
  34. package/tests/__snapshots__/lint.test.ts.snap +29 -1
  35. package/tests/check-fix.test.ts +26 -2
  36. package/tests/check-stable/check-limit-warning/deployed.most +8 -0
  37. package/tests/check-stable/check-limit-warning/migrations/20250101_000000_Init.mo +5 -0
  38. package/tests/check-stable/check-limit-warning/migrations/20250201_000000_AddField.mo +9 -0
  39. package/tests/check-stable/check-limit-warning/migrations/20250301_000000_AddD.mo +10 -0
  40. package/tests/check-stable/check-limit-warning/mops.toml +15 -0
  41. package/tests/check-stable/check-limit-warning/src/main.mo +12 -0
  42. package/tests/check-stable.test.ts +24 -0
  43. package/tests/check.test.ts +9 -0
  44. package/tests/lint-extra-example-rules/lint/migration-self-contained/migration-self-contained.toml +7 -0
  45. package/tests/lint-extra-example-rules/migrations/20250101_000000_Init.mo +11 -0
  46. package/tests/lint-extra-example-rules/mops.toml +1 -0
  47. package/tests/lint.test.ts +1 -1
  48. package/tests/migrations-most.test.ts +64 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Next
4
4
 
5
+ ## 2.15.2
6
+
7
+ - `mops check-stable` (and the stable check inside `mops check`) reports when `[canisters.<name>.migrations].check-limit` is set but more migrations are pending than the limit allows. If the compatibility check failed, the check-limit diagnostic replaces the misleading `moc` error; if it passed anyway, a warning is shown. Compares the deployed `.most` baseline against the local chain; use `--no-check-limit` to suppress.
8
+
9
+ ## 2.15.1
10
+
11
+ - `mops check --fix` no longer aborts when a fixable file is read-only (e.g. a frozen migration chain file deliberately `chmod`'d to remove write access). The autofixer now skips such files with a warning and continues fixing the rest, instead of crashing the whole run on `EACCES`/`EPERM`.
12
+
13
+ - Load the PocketIC client lazily, only when a command actually starts a replica. Commands like `mops check`, `mops build`, and `mops install` no longer pay to load it (and its `@icp-sdk/core` dependency) at startup. This also unblocks running the CLI via `tsx` in local dev, where `pic-js-mops` (shipped as ESM without `"type": "module"`) fails to resolve as a static import.
14
+
15
+ - `mops bench --verbose` is now actually verbose. It prints the benchmark pipeline up front — compiler version, replica + version, GC, profile, and whether the wasm is optimized (`dfx` post-optimizes with `optimize: "cycles"` via ic-wasm on deploy; `pocket-ic` runs the raw `moc` output) — logs the full `moc` build command, and streams the compiler and `dfx` output instead of capturing and discarding it. Notably this surfaces dfx's `WARNING: Failed to optimize the Wasm module`, which dfx prints (and then silently deploys the unoptimized module) when `optimize: "cycles"` fails — e.g. on multi-value modules that the bundled ic-wasm can't process. Previously all of this was hidden even with `--verbose`.
16
+
5
17
  ## 2.15.0
6
18
 
7
19
  - Fix `mops check --fix` corrupting source on lines containing multi-byte UTF-8 characters (e.g. `Char.toNat32('京')` dropping its trailing `)`). The autofixer was feeding moc's UTF-8 byte columns into LSP's UTF-16 position API, mis-applying every edit past the first non-ASCII byte on the line. When moc emits `byte_start`/`byte_end` (1.10.0 and newer) the fixer now applies edits byte-accurately; older moc still falls back to the line+column path (unchanged behavior, still ASCII-only).
package/bundle/cli.tgz CHANGED
Binary file
package/cli.ts CHANGED
@@ -102,14 +102,26 @@ function parseExtraArgs(variadicArgs?: string[]): {
102
102
  // Shared `--help` section describing the enhanced migration `check-limit`
103
103
  // trimming and its override flag, so the limit behaviour is discoverable
104
104
  // from `--help`. `withFix` appends the `--fix` hint for commands that support it.
105
- function enhancedMigrationHelp(withFix: boolean): string {
106
- const example = withFix ? " (e.g. to --fix older migrations)" : "";
107
- return (
105
+ // `withPendingWarning` adds the stable-check pending-migration warning (check / check-stable only).
106
+ function enhancedMigrationHelp(
107
+ options: {
108
+ withFix?: boolean;
109
+ withPendingWarning?: boolean;
110
+ } = {},
111
+ ): string {
112
+ const example = options.withFix ? " (e.g. to --fix older migrations)" : "";
113
+ let text =
108
114
  "\nEnhanced migration ([canisters.<name>.migrations]):\n" +
109
115
  " The canister is checked against its migration chain. [migrations].check-limit\n" +
110
116
  " trims it to the last N migrations (older ones are skipped). Pass --no-check-limit\n" +
111
- ` to use the full chain${example}.`
112
- );
117
+ ` to use the full chain${example}.`;
118
+ if (options.withPendingWarning) {
119
+ text +=
120
+ "\n When check-limit is set, the stable check reports if more migrations are pending\n" +
121
+ " (relative to the deployed .most baseline) than the limit allows — as an error if\n" +
122
+ " compat failed, otherwise a warning.";
123
+ }
124
+ return text;
113
125
  }
114
126
 
115
127
  program.name("mops");
@@ -372,14 +384,17 @@ program
372
384
  .addOption(
373
385
  new Option(
374
386
  "--no-check-limit",
375
- "Check the full migration chain, ignoring [migrations].check-limit",
387
+ "Use the full migration chain, ignoring [migrations].check-limit; also suppresses the pending-migration warning",
376
388
  ),
377
389
  )
378
390
  .addHelpText(
379
391
  "after",
380
392
  "\nArguments after -- are forwarded directly to moc, e.g.:\n $ mops check -- -Werror",
381
393
  )
382
- .addHelpText("after", enhancedMigrationHelp(true))
394
+ .addHelpText(
395
+ "after",
396
+ enhancedMigrationHelp({ withFix: true, withPendingWarning: true }),
397
+ )
383
398
  .allowUnknownOption(true)
384
399
  .action(async (args, options) => {
385
400
  checkConfigFile(true);
@@ -419,14 +434,14 @@ program
419
434
  .addOption(
420
435
  new Option(
421
436
  "--no-check-limit",
422
- "Check the full migration chain, ignoring [migrations].check-limit",
437
+ "Use the full migration chain, ignoring [migrations].check-limit; also suppresses the pending-migration warning",
423
438
  ),
424
439
  )
425
440
  .addHelpText(
426
441
  "after",
427
442
  "\nArguments after -- are forwarded directly to moc, e.g.:\n $ mops check-stable -- -Werror",
428
443
  )
429
- .addHelpText("after", enhancedMigrationHelp(false))
444
+ .addHelpText("after", enhancedMigrationHelp({ withPendingWarning: true }))
430
445
  .allowUnknownOption(true)
431
446
  .action(async (args, options) => {
432
447
  checkConfigFile(true);
@@ -543,7 +558,12 @@ program
543
558
  ),
544
559
  )
545
560
  // .addOption(new Option('--force-gc', 'Force GC'))
546
- .addOption(new Option("--verbose", "Show more information"))
561
+ .addOption(
562
+ new Option(
563
+ "--verbose",
564
+ "Print the benchmark pipeline (compiler, replica, GC, optimization) and stream compiler/replica output, including dfx optimization warnings",
565
+ ),
566
+ )
547
567
  .action(async (filter, options) => {
548
568
  checkConfigFile(true);
549
569
  await installAll({
@@ -974,7 +994,7 @@ program
974
994
  "after",
975
995
  "\nArguments after -- are forwarded directly to lintoko, e.g.:\n $ mops lint -- --severity warning",
976
996
  )
977
- .addHelpText("after", enhancedMigrationHelp(true))
997
+ .addHelpText("after", enhancedMigrationHelp({ withFix: true }))
978
998
  .allowUnknownOption(true)
979
999
  .action(async (filter, options) => {
980
1000
  checkConfigFile(true);
@@ -74,7 +74,8 @@ export class BenchReplica {
74
74
  if (this.type === "dfx" || this.type === "dfx-pocket-ic") {
75
75
  await execaCommand(
76
76
  `dfx deploy ${name} --mode reinstall --yes --identity anonymous`,
77
- { cwd, stdio: this.verbose ? "pipe" : ["pipe", "ignore", "pipe"] },
77
+ // `inherit` so dfx output is streamed under --verbose (incl. its `Failed to optimize` warning)
78
+ { cwd, stdio: this.verbose ? "inherit" : ["pipe", "ignore", "pipe"] },
78
79
  );
79
80
  let canisterId = execSync(`dfx canister id ${name}`, { cwd })
80
81
  .toString()
package/commands/bench.ts CHANGED
@@ -101,7 +101,28 @@ export async function bench(
101
101
 
102
102
  warnIfDfxReplica(replicaType, optionsArg.replica === "dfx");
103
103
 
104
- options.verbose && console.log(options);
104
+ if (options.verbose) {
105
+ // `dfx` post-optimizes the wasm on deploy (`optimize: "cycles"`, via ic-wasm);
106
+ // `pocket-ic` runs the raw moc output. This changes instruction counts, so surface it.
107
+ let optimize =
108
+ replicaType === "dfx" || replicaType === "dfx-pocket-ic"
109
+ ? 'dfx `optimize: "cycles"` (ic-wasm) on deploy'
110
+ : "none (raw moc output)";
111
+ console.log(chalk.gray("Benchmark pipeline:"));
112
+ console.log(chalk.gray(` compiler: moc ${options.compilerVersion}`));
113
+ console.log(
114
+ chalk.gray(
115
+ ` replica: ${replicaType}${options.replicaVersion ? ` ${options.replicaVersion}` : ""}`,
116
+ ),
117
+ );
118
+ console.log(
119
+ chalk.gray(
120
+ ` gc: ${options.gc}${options.forceGc ? " (forced)" : ""}`,
121
+ ),
122
+ );
123
+ console.log(chalk.gray(` profile: ${options.profile}`));
124
+ console.log(chalk.gray(` optimize: ${optimize}`));
125
+ }
105
126
 
106
127
  let replica = new BenchReplica(replicaType, options.verbose);
107
128
 
@@ -303,14 +324,16 @@ async function deployBenchFile(
303
324
  // build canister
304
325
  let mocPath = getMocPath();
305
326
  let mocArgs = getMocArgs(options);
306
- options.verbose && console.time(`build ${canisterName}`);
307
- await execaCommand(
308
- `${mocPath} -c --idl canister.mo ${globalMocArgs.join(" ")} ${mocArgs} ${(await sources({ cwd: tempDir })).join(" ")}`,
309
- {
310
- cwd: tempDir,
311
- stdio: options.verbose ? "pipe" : ["pipe", "ignore", "pipe"],
312
- },
313
- );
327
+ let buildCmd = `${mocPath} -c --idl canister.mo ${globalMocArgs.join(" ")} ${mocArgs} ${(await sources({ cwd: tempDir })).join(" ")}`;
328
+ if (options.verbose) {
329
+ console.log(chalk.gray(`[${canisterName}] ${buildCmd}`));
330
+ console.time(`build ${canisterName}`);
331
+ }
332
+ await execaCommand(buildCmd, {
333
+ cwd: tempDir,
334
+ // `inherit` so the compiler output (warnings/errors) is streamed under --verbose
335
+ stdio: options.verbose ? "inherit" : ["pipe", "ignore", "pipe"],
336
+ });
314
337
  options.verbose && console.timeEnd(`build ${canisterName}`);
315
338
 
316
339
  // deploy canister
@@ -4,9 +4,13 @@ import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { execa } from "execa";
6
6
  import { cliError } from "../error.js";
7
- import { prepareMigrationArgs } from "../helpers/migrations.js";
7
+ import {
8
+ getCheckLimitPendingIssue,
9
+ prepareMigrationArgs,
10
+ reportCheckLimitPendingIssue,
11
+ } from "../helpers/migrations.js";
8
12
  import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js";
9
- import { CanisterConfig } from "../types.js";
13
+ import { CanisterConfig, MigrationsConfig } from "../types.js";
10
14
  import {
11
15
  filterCanisters,
12
16
  looksLikeFile,
@@ -103,6 +107,7 @@ export async function checkStable(
103
107
  mocPath,
104
108
  globalMocArgs,
105
109
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
110
+ migrations: canister.migrations,
106
111
  options,
107
112
  });
108
113
  } finally {
@@ -146,6 +151,7 @@ export async function checkStable(
146
151
  globalMocArgs,
147
152
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
148
153
  sources,
154
+ migrations: canister.migrations,
149
155
  options,
150
156
  });
151
157
  } finally {
@@ -173,6 +179,7 @@ export interface RunStableCheckParams {
173
179
  globalMocArgs: string[];
174
180
  canisterArgs: string[];
175
181
  sources?: string[];
182
+ migrations?: MigrationsConfig;
176
183
  options?: Partial<CheckStableOptions>;
177
184
  }
178
185
 
@@ -240,7 +247,17 @@ export async function runStableCheck(
240
247
  reject: false,
241
248
  });
242
249
 
243
- if (result.exitCode !== 0) {
250
+ const issue = getCheckLimitPendingIssue(
251
+ params.migrations,
252
+ canisterName,
253
+ oldMostPath,
254
+ options.checkLimit === false,
255
+ isOldMostFile,
256
+ );
257
+
258
+ if (issue) {
259
+ reportCheckLimitPendingIssue(issue, result.exitCode !== 0);
260
+ } else if (result.exitCode !== 0) {
244
261
  if (result.stderr) {
245
262
  console.error(result.stderr);
246
263
  }
package/commands/check.ts CHANGED
@@ -230,7 +230,12 @@ async function checkCanisters(
230
230
  globalMocArgs,
231
231
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
232
232
  sources,
233
- options: { verbose: options.verbose, extraArgs: options.extraArgs },
233
+ migrations: canister.migrations,
234
+ options: {
235
+ verbose: options.verbose,
236
+ extraArgs: options.extraArgs,
237
+ checkLimit: options.checkLimit,
238
+ },
234
239
  });
235
240
  }
236
241
  } finally {
package/dist/cli.js CHANGED
@@ -65,12 +65,20 @@ function parseExtraArgs(variadicArgs) {
65
65
  // Shared `--help` section describing the enhanced migration `check-limit`
66
66
  // trimming and its override flag, so the limit behaviour is discoverable
67
67
  // from `--help`. `withFix` appends the `--fix` hint for commands that support it.
68
- function enhancedMigrationHelp(withFix) {
69
- const example = withFix ? " (e.g. to --fix older migrations)" : "";
70
- return ("\nEnhanced migration ([canisters.<name>.migrations]):\n" +
68
+ // `withPendingWarning` adds the stable-check pending-migration warning (check / check-stable only).
69
+ function enhancedMigrationHelp(options = {}) {
70
+ const example = options.withFix ? " (e.g. to --fix older migrations)" : "";
71
+ let text = "\nEnhanced migration ([canisters.<name>.migrations]):\n" +
71
72
  " The canister is checked against its migration chain. [migrations].check-limit\n" +
72
73
  " trims it to the last N migrations (older ones are skipped). Pass --no-check-limit\n" +
73
- ` to use the full chain${example}.`);
74
+ ` to use the full chain${example}.`;
75
+ if (options.withPendingWarning) {
76
+ text +=
77
+ "\n When check-limit is set, the stable check reports if more migrations are pending\n" +
78
+ " (relative to the deployed .most baseline) than the limit allows — as an error if\n" +
79
+ " compat failed, otherwise a warning.";
80
+ }
81
+ return text;
74
82
  }
75
83
  program.name("mops");
76
84
  // --version
@@ -286,9 +294,9 @@ program
286
294
  .description("Check Motoko canisters or files for syntax errors and type issues. Arguments can be canister names or file paths. If no arguments are given, checks all canisters from mops.toml. Also runs stable compatibility checks for canisters with [check-stable] configured, and runs linting if lintoko is configured in [toolchain]")
287
295
  .option("--verbose", "Verbose console output")
288
296
  .addOption(new Option("--fix", "Apply autofixes to all files, including transitively imported ones"))
289
- .addOption(new Option("--no-check-limit", "Check the full migration chain, ignoring [migrations].check-limit"))
297
+ .addOption(new Option("--no-check-limit", "Use the full migration chain, ignoring [migrations].check-limit; also suppresses the pending-migration warning"))
290
298
  .addHelpText("after", "\nArguments after -- are forwarded directly to moc, e.g.:\n $ mops check -- -Werror")
291
- .addHelpText("after", enhancedMigrationHelp(true))
299
+ .addHelpText("after", enhancedMigrationHelp({ withFix: true, withPendingWarning: true }))
292
300
  .allowUnknownOption(true)
293
301
  .action(async (args, options) => {
294
302
  checkConfigFile(true);
@@ -321,9 +329,9 @@ program
321
329
  .command("check-stable [args...]")
322
330
  .description("Check stable variable compatibility. With no arguments, checks all canisters with [check-stable] configured. Arguments can be canister names or an old file path followed by an optional canister name")
323
331
  .option("--verbose", "Verbose console output")
324
- .addOption(new Option("--no-check-limit", "Check the full migration chain, ignoring [migrations].check-limit"))
332
+ .addOption(new Option("--no-check-limit", "Use the full migration chain, ignoring [migrations].check-limit; also suppresses the pending-migration warning"))
325
333
  .addHelpText("after", "\nArguments after -- are forwarded directly to moc, e.g.:\n $ mops check-stable -- -Werror")
326
- .addHelpText("after", enhancedMigrationHelp(false))
334
+ .addHelpText("after", enhancedMigrationHelp({ withPendingWarning: true }))
327
335
  .allowUnknownOption(true)
328
336
  .action(async (args, options) => {
329
337
  checkConfigFile(true);
@@ -393,7 +401,7 @@ program
393
401
  .addOption(new Option("--save", "Save benchmark results to .bench/<filename>.json"))
394
402
  .addOption(new Option("--compare", "Run benchmark and compare results with .bench/<filename>.json"))
395
403
  // .addOption(new Option('--force-gc', 'Force GC'))
396
- .addOption(new Option("--verbose", "Show more information"))
404
+ .addOption(new Option("--verbose", "Print the benchmark pipeline (compiler, replica, GC, optimization) and stream compiler/replica output, including dfx optimization warnings"))
397
405
  .action(async (filter, options) => {
398
406
  checkConfigFile(true);
399
407
  await installAll({
@@ -693,7 +701,7 @@ program
693
701
  .addOption(new Option("-r, --rules <directory...>", "Directories containing rules (can be used multiple times)"))
694
702
  .addOption(new Option("--no-check-limit", "Lint the full migration chain, ignoring [migrations].check-limit"))
695
703
  .addHelpText("after", "\nArguments after -- are forwarded directly to lintoko, e.g.:\n $ mops lint -- --severity warning")
696
- .addHelpText("after", enhancedMigrationHelp(true))
704
+ .addHelpText("after", enhancedMigrationHelp({ withFix: true }))
697
705
  .allowUnknownOption(true)
698
706
  .action(async (filter, options) => {
699
707
  checkConfigFile(true);
@@ -55,7 +55,9 @@ export class BenchReplica {
55
55
  }
56
56
  async deploy(name, wasm, cwd = process.cwd()) {
57
57
  if (this.type === "dfx" || this.type === "dfx-pocket-ic") {
58
- await execaCommand(`dfx deploy ${name} --mode reinstall --yes --identity anonymous`, { cwd, stdio: this.verbose ? "pipe" : ["pipe", "ignore", "pipe"] });
58
+ await execaCommand(`dfx deploy ${name} --mode reinstall --yes --identity anonymous`,
59
+ // `inherit` so dfx output is streamed under --verbose (incl. its `Failed to optimize` warning)
60
+ { cwd, stdio: this.verbose ? "inherit" : ["pipe", "ignore", "pipe"] });
59
61
  let canisterId = execSync(`dfx canister id ${name}`, { cwd })
60
62
  .toString()
61
63
  .trim();
@@ -61,7 +61,19 @@ export async function bench(filter = "", optionsArg = {}) {
61
61
  options.replicaVersion = config.toolchain?.["pocket-ic"] || "";
62
62
  }
63
63
  warnIfDfxReplica(replicaType, optionsArg.replica === "dfx");
64
- options.verbose && console.log(options);
64
+ if (options.verbose) {
65
+ // `dfx` post-optimizes the wasm on deploy (`optimize: "cycles"`, via ic-wasm);
66
+ // `pocket-ic` runs the raw moc output. This changes instruction counts, so surface it.
67
+ let optimize = replicaType === "dfx" || replicaType === "dfx-pocket-ic"
68
+ ? 'dfx `optimize: "cycles"` (ic-wasm) on deploy'
69
+ : "none (raw moc output)";
70
+ console.log(chalk.gray("Benchmark pipeline:"));
71
+ console.log(chalk.gray(` compiler: moc ${options.compilerVersion}`));
72
+ console.log(chalk.gray(` replica: ${replicaType}${options.replicaVersion ? ` ${options.replicaVersion}` : ""}`));
73
+ console.log(chalk.gray(` gc: ${options.gc}${options.forceGc ? " (forced)" : ""}`));
74
+ console.log(chalk.gray(` profile: ${options.profile}`));
75
+ console.log(chalk.gray(` optimize: ${optimize}`));
76
+ }
65
77
  let replica = new BenchReplica(replicaType, options.verbose);
66
78
  let rootDir = getRootDir();
67
79
  let globStr = "**/bench?(mark)/**/*.bench.mo";
@@ -207,10 +219,15 @@ async function deployBenchFile(file, options, replica, globalMocArgs) {
207
219
  // build canister
208
220
  let mocPath = getMocPath();
209
221
  let mocArgs = getMocArgs(options);
210
- options.verbose && console.time(`build ${canisterName}`);
211
- await execaCommand(`${mocPath} -c --idl canister.mo ${globalMocArgs.join(" ")} ${mocArgs} ${(await sources({ cwd: tempDir })).join(" ")}`, {
222
+ let buildCmd = `${mocPath} -c --idl canister.mo ${globalMocArgs.join(" ")} ${mocArgs} ${(await sources({ cwd: tempDir })).join(" ")}`;
223
+ if (options.verbose) {
224
+ console.log(chalk.gray(`[${canisterName}] ${buildCmd}`));
225
+ console.time(`build ${canisterName}`);
226
+ }
227
+ await execaCommand(buildCmd, {
212
228
  cwd: tempDir,
213
- stdio: options.verbose ? "pipe" : ["pipe", "ignore", "pipe"],
229
+ // `inherit` so the compiler output (warnings/errors) is streamed under --verbose
230
+ stdio: options.verbose ? "inherit" : ["pipe", "ignore", "pipe"],
214
231
  });
215
232
  options.verbose && console.timeEnd(`build ${canisterName}`);
216
233
  // deploy canister
@@ -1,4 +1,4 @@
1
- import { CanisterConfig } from "../types.js";
1
+ import { CanisterConfig, MigrationsConfig } from "../types.js";
2
2
  export interface CheckStableOptions {
3
3
  verbose: boolean;
4
4
  extraArgs: string[];
@@ -17,6 +17,7 @@ export interface RunStableCheckParams {
17
17
  globalMocArgs: string[];
18
18
  canisterArgs: string[];
19
19
  sources?: string[];
20
+ migrations?: MigrationsConfig;
20
21
  options?: Partial<CheckStableOptions>;
21
22
  }
22
23
  export declare function runStableCheck(params: RunStableCheckParams): Promise<void>;
@@ -4,7 +4,7 @@ import { rm } from "node:fs/promises";
4
4
  import chalk from "chalk";
5
5
  import { execa } from "execa";
6
6
  import { cliError } from "../error.js";
7
- import { prepareMigrationArgs } from "../helpers/migrations.js";
7
+ import { getCheckLimitPendingIssue, prepareMigrationArgs, reportCheckLimitPendingIssue, } from "../helpers/migrations.js";
8
8
  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";
@@ -61,6 +61,7 @@ export async function checkStable(args, options = {}) {
61
61
  mocPath,
62
62
  globalMocArgs,
63
63
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
64
+ migrations: canister.migrations,
64
65
  options,
65
66
  });
66
67
  }
@@ -95,6 +96,7 @@ export async function checkStable(args, options = {}) {
95
96
  globalMocArgs,
96
97
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
97
98
  sources,
99
+ migrations: canister.migrations,
98
100
  options,
99
101
  });
100
102
  }
@@ -136,7 +138,11 @@ export async function runStableCheck(params) {
136
138
  stdio: "pipe",
137
139
  reject: false,
138
140
  });
139
- if (result.exitCode !== 0) {
141
+ const issue = getCheckLimitPendingIssue(params.migrations, canisterName, oldMostPath, options.checkLimit === false, isOldMostFile);
142
+ if (issue) {
143
+ reportCheckLimitPendingIssue(issue, result.exitCode !== 0);
144
+ }
145
+ else if (result.exitCode !== 0) {
140
146
  if (result.stderr) {
141
147
  console.error(result.stderr);
142
148
  }
@@ -144,7 +144,12 @@ async function checkCanisters(config, canisters, options) {
144
144
  globalMocArgs,
145
145
  canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
146
146
  sources,
147
- options: { verbose: options.verbose, extraArgs: options.extraArgs },
147
+ migrations: canister.migrations,
148
+ options: {
149
+ verbose: options.verbose,
150
+ extraArgs: options.extraArgs,
151
+ checkLimit: options.checkLimit,
152
+ },
148
153
  });
149
154
  }
150
155
  }
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { relative, resolve } from "node:path";
3
+ import chalk from "chalk";
3
4
  import { execa } from "execa";
4
5
  import { TextDocument } from "vscode-languageserver-textdocument";
5
6
  export function parseDiagnostics(stdout) {
@@ -110,6 +111,9 @@ function applyDiagnosticFixes(content, fixes) {
110
111
  const MAX_FIX_ITERATIONS = 10;
111
112
  export async function autofixMotoko(mocPath, files, mocArgs) {
112
113
  const fixedFilesCodes = new Map();
114
+ // Frozen migration files are often chmod'd read-only; warn once and skip
115
+ // them rather than aborting the whole run on EACCES/EPERM.
116
+ const readOnlySkipped = new Set();
113
117
  for (let iteration = 0; iteration < MAX_FIX_ITERATIONS; iteration++) {
114
118
  const fixesByFile = new Map();
115
119
  for (const file of files) {
@@ -126,12 +130,25 @@ export async function autofixMotoko(mocPath, files, mocArgs) {
126
130
  }
127
131
  let progress = false;
128
132
  for (const [file, fixes] of fixesByFile) {
133
+ if (readOnlySkipped.has(file)) {
134
+ continue;
135
+ }
129
136
  const original = await readFile(file, "utf-8");
130
137
  const { text: result, appliedCodes } = applyDiagnosticFixes(original, fixes);
131
138
  if (result === original) {
132
139
  continue;
133
140
  }
134
- await writeFile(file, result, "utf-8");
141
+ try {
142
+ await writeFile(file, result, "utf-8");
143
+ }
144
+ catch (err) {
145
+ if (err?.code === "EACCES" || err?.code === "EPERM") {
146
+ readOnlySkipped.add(file);
147
+ console.warn(chalk.yellow(`Skipped read-only file ${relative(process.cwd(), file)} (${appliedCodes.length} fix(es) not applied)`));
148
+ continue;
149
+ }
150
+ throw err;
151
+ }
135
152
  progress = true;
136
153
  const existing = fixedFilesCodes.get(file) ?? [];
137
154
  existing.push(...appliedCodes);
@@ -8,6 +8,22 @@ 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, ignoreLimit?: boolean): Promise<MigrationArgsResult>;
11
+ /**
12
+ * Local chain (+ next) files not yet recorded in the deployed `.most` baseline.
13
+ */
14
+ export declare function getPendingMigrationFiles(migrations: MigrationsConfig, canisterName: string, appliedNames: string[]): string[];
15
+ /**
16
+ * Pending migrations exceed `check-limit` relative to the deployed `.most` baseline.
17
+ */
18
+ export interface CheckLimitPendingIssue {
19
+ canisterName: string;
20
+ pending: string[];
21
+ checkLimit: number;
22
+ latestApplied: string | null;
23
+ }
24
+ export declare function getCheckLimitPendingIssue(migrations: MigrationsConfig | undefined, canisterName: string, oldMostPath: string, ignoreCheckLimit: boolean, baselineIsMostFile: boolean): CheckLimitPendingIssue | null;
25
+ /** Warn on accidental compat pass; fail with this diagnostic when compat already failed. */
26
+ export declare function reportCheckLimitPendingIssue(issue: CheckLimitPendingIssue, compatFailed: boolean): void;
11
27
  /**
12
28
  * Absolute paths of chain migration files that `mops lint` should skip,
13
29
  * mirroring the `check-limit` trimming applied to `moc` during `mops check`.
@@ -1,10 +1,11 @@
1
- import { existsSync, mkdirSync, mkdtempSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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
7
  import { resolveCanisterConfigs } from "./resolve-canisters.js";
8
+ import { latestAppliedMigrationName, migrationBasename, parseMostAppliedMigrationNames, } from "./parse-most.js";
8
9
  function stagedMigrationsDir(chainDir, canisterName) {
9
10
  return join(dirname(chainDir), `.migrations-${canisterName}`);
10
11
  }
@@ -151,6 +152,75 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
151
152
  },
152
153
  };
153
154
  }
155
+ /**
156
+ * Local chain (+ next) files not yet recorded in the deployed `.most` baseline.
157
+ */
158
+ export function getPendingMigrationFiles(migrations, canisterName, appliedNames) {
159
+ validateMigrationsConfig(migrations, canisterName);
160
+ const chainDir = resolveConfigPath(migrations.chain);
161
+ const nextDir = migrations.next
162
+ ? resolveConfigPath(migrations.next)
163
+ : undefined;
164
+ const nextFile = nextDir ? getNextMigrationFile(nextDir) : null;
165
+ const all = getMigrationFiles(chainDir);
166
+ if (nextFile) {
167
+ all.push(nextFile);
168
+ }
169
+ const highWaterMark = appliedNames.length > 0 ? latestAppliedMigrationName(appliedNames) : "";
170
+ return all.filter((file) => migrationBasename(file) > highWaterMark);
171
+ }
172
+ export function getCheckLimitPendingIssue(migrations, canisterName, oldMostPath, ignoreCheckLimit, baselineIsMostFile) {
173
+ if (!migrations || ignoreCheckLimit || !baselineIsMostFile) {
174
+ return null;
175
+ }
176
+ const checkLimit = migrations["check-limit"];
177
+ if (checkLimit === undefined) {
178
+ return null;
179
+ }
180
+ let appliedNames;
181
+ try {
182
+ appliedNames = parseMostAppliedMigrationNames(readFileSync(oldMostPath, "utf8"));
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ if (appliedNames === null) {
188
+ return null;
189
+ }
190
+ const pending = getPendingMigrationFiles(migrations, canisterName, appliedNames);
191
+ if (pending.length <= checkLimit) {
192
+ return null;
193
+ }
194
+ return {
195
+ canisterName,
196
+ pending,
197
+ checkLimit,
198
+ latestApplied: appliedNames.length > 0 ? latestAppliedMigrationName(appliedNames) : null,
199
+ };
200
+ }
201
+ function formatCheckLimitPendingLines(issue) {
202
+ const { canisterName, pending, checkLimit } = issue;
203
+ return [
204
+ `Canister '${canisterName}' has ${pending.length} pending migration(s) but check-limit=${checkLimit}. ` +
205
+ `Fold all changes into the latest pending migration: ${pending[pending.length - 1]}`,
206
+ ` Pending: ${pending.join(", ")}`,
207
+ ...(issue.latestApplied
208
+ ? [` Latest already applied migration: ${issue.latestApplied}`]
209
+ : []),
210
+ ];
211
+ }
212
+ /** Warn on accidental compat pass; fail with this diagnostic when compat already failed. */
213
+ export function reportCheckLimitPendingIssue(issue, compatFailed) {
214
+ const lines = formatCheckLimitPendingLines(issue);
215
+ if (compatFailed) {
216
+ cliError(`✗ Stable compatibility check failed for canister '${issue.canisterName}': too many pending migrations for check-limit=${issue.checkLimit}\n` +
217
+ lines.join("\n"));
218
+ }
219
+ console.warn(chalk.yellow(`WARN: ${lines[0]}`));
220
+ for (const line of lines.slice(1)) {
221
+ console.warn(chalk.yellow(line));
222
+ }
223
+ }
154
224
  /**
155
225
  * Absolute paths of chain migration files that `mops lint` should skip,
156
226
  * mirroring the `check-limit` trimming applied to `moc` during `mops check`.
@@ -0,0 +1,5 @@
1
+ /** Enhanced-migration names recorded in a Version 4.0.0 `.most` chain block. */
2
+ export declare function parseMostAppliedMigrationNames(content: string): string[] | null;
3
+ export declare function migrationBasename(file: string): string;
4
+ /** Lexicographic max of applied migration names (matches chain file sort order). */
5
+ export declare function latestAppliedMigrationName(appliedNames: string[]): string;
@@ -0,0 +1,28 @@
1
+ /** Enhanced-migration names recorded in a Version 4.0.0 `.most` chain block. */
2
+ export function parseMostAppliedMigrationNames(content) {
3
+ const version = content.match(/^\/\/ Version: ([^\n]+)/m)?.[1]?.trim();
4
+ if (!version) {
5
+ return null;
6
+ }
7
+ if (version !== "4.0.0") {
8
+ return [];
9
+ }
10
+ // Safe: actor types in type aliases are always indented (col ≥ 2); the main actor is at col 0.
11
+ const actorIdx = content.search(/\nactor\b/);
12
+ if (actorIdx < 0) {
13
+ return null;
14
+ }
15
+ const chainBlock = content.slice(0, actorIdx);
16
+ const names = [];
17
+ for (const match of chainBlock.matchAll(/[{;]\s*"([^"]+)"\s*:/gs)) {
18
+ names.push(match[1]);
19
+ }
20
+ return names;
21
+ }
22
+ export function migrationBasename(file) {
23
+ return file.endsWith(".mo") ? file.slice(0, -3) : file;
24
+ }
25
+ /** Lexicographic max of applied migration names (matches chain file sort order). */
26
+ export function latestAppliedMigrationName(appliedNames) {
27
+ return appliedNames.reduce((max, name) => (name > max ? name : max), "");
28
+ }
@@ -1,5 +1,5 @@
1
- import { PocketIc, PocketIcServer } from "pic-ic";
2
- import { PocketIc as PocketIcModern, PocketIcServer as PocketIcServerModern, type StartServerOptions } from "pic-js-mops";
1
+ import type { PocketIc, PocketIcServer } from "pic-ic";
2
+ import type { PocketIc as PocketIcModern, PocketIcServer as PocketIcServerModern, StartServerOptions } from "pic-js-mops";
3
3
  export type AnyPocketIcServer = PocketIcServer | PocketIcServerModern;
4
4
  export type AnyPocketIc = PocketIc | PocketIcModern;
5
5
  export type AnySetupCanister = PocketIc["setupCanister"] & PocketIcModern["setupCanister"];