ic-mops 2.15.1 → 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 (37) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/cli.ts +25 -10
  4. package/commands/check-stable.ts +20 -3
  5. package/commands/check.ts +6 -1
  6. package/dist/cli.js +17 -9
  7. package/dist/commands/check-stable.d.ts +2 -1
  8. package/dist/commands/check-stable.js +8 -2
  9. package/dist/commands/check.js +6 -1
  10. package/dist/helpers/migrations.d.ts +16 -0
  11. package/dist/helpers/migrations.js +71 -1
  12. package/dist/helpers/parse-most.d.ts +5 -0
  13. package/dist/helpers/parse-most.js +28 -0
  14. package/dist/package.json +1 -1
  15. package/dist/tests/check-stable.test.js +15 -0
  16. package/dist/tests/check.test.js +5 -0
  17. package/dist/tests/lint.test.js +1 -1
  18. package/dist/tests/migrations-most.test.d.ts +1 -0
  19. package/dist/tests/migrations-most.test.js +47 -0
  20. package/helpers/migrations.ts +117 -0
  21. package/helpers/parse-most.ts +32 -0
  22. package/package.json +1 -1
  23. package/tests/__snapshots__/check-stable.test.ts.snap +11 -0
  24. package/tests/__snapshots__/lint.test.ts.snap +29 -1
  25. package/tests/check-stable/check-limit-warning/deployed.most +8 -0
  26. package/tests/check-stable/check-limit-warning/migrations/20250101_000000_Init.mo +5 -0
  27. package/tests/check-stable/check-limit-warning/migrations/20250201_000000_AddField.mo +9 -0
  28. package/tests/check-stable/check-limit-warning/migrations/20250301_000000_AddD.mo +10 -0
  29. package/tests/check-stable/check-limit-warning/mops.toml +15 -0
  30. package/tests/check-stable/check-limit-warning/src/main.mo +12 -0
  31. package/tests/check-stable.test.ts +24 -0
  32. package/tests/check.test.ts +9 -0
  33. package/tests/lint-extra-example-rules/lint/migration-self-contained/migration-self-contained.toml +7 -0
  34. package/tests/lint-extra-example-rules/migrations/20250101_000000_Init.mo +11 -0
  35. package/tests/lint-extra-example-rules/mops.toml +1 -0
  36. package/tests/lint.test.ts +1 -1
  37. package/tests/migrations-most.test.ts +64 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
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
+
5
9
  ## 2.15.1
6
10
 
7
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`.
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);
@@ -979,7 +994,7 @@ program
979
994
  "after",
980
995
  "\nArguments after -- are forwarded directly to lintoko, e.g.:\n $ mops lint -- --severity warning",
981
996
  )
982
- .addHelpText("after", enhancedMigrationHelp(true))
997
+ .addHelpText("after", enhancedMigrationHelp({ withFix: true }))
983
998
  .allowUnknownOption(true)
984
999
  .action(async (filter, options) => {
985
1000
  checkConfigFile(true);
@@ -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);
@@ -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);
@@ -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
  }
@@ -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
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.15.1",
3
+ "version": "2.15.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -94,4 +94,19 @@ describe("check-stable", () => {
94
94
  expect(result.stdout).toMatch(/Stable compatibility check passed/);
95
95
  }
96
96
  }, 60_000);
97
+ test("fails with check-limit diagnostic when pending migrations exceed check-limit", async () => {
98
+ const cwd = path.join(import.meta.dirname, "check-stable/check-limit-warning");
99
+ await cliSnapshot(["check-stable"], { cwd }, 1);
100
+ });
101
+ test("does not warn when deployed baseline matches the chain", async () => {
102
+ const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
103
+ const result = await cli(["check-stable"], { cwd });
104
+ expect(result.exitCode).toBe(0);
105
+ expect(result.stderr).not.toMatch(/pending migration\(s\) but check-limit/);
106
+ });
107
+ test("--no-check-limit suppresses pending migration warning", async () => {
108
+ const cwd = path.join(import.meta.dirname, "check-stable/check-limit-warning");
109
+ const result = await cli(["check-stable", "--no-check-limit"], { cwd });
110
+ expect(result.stderr).not.toMatch(/pending migration\(s\) but check-limit/);
111
+ });
97
112
  });
@@ -158,4 +158,9 @@ describe("check", () => {
158
158
  expect(result.stdout).not.toMatch(/\.migrations-/);
159
159
  expect(result.stdout).not.toMatch(/-A=M0254/);
160
160
  });
161
+ test("--no-check-limit suppresses pending migration warning in check", async () => {
162
+ const cwd = path.join(import.meta.dirname, "check-stable/check-limit-warning");
163
+ const result = await cli(["check", "--no-check-limit"], { cwd });
164
+ expect(result.stderr).not.toMatch(/pending migration\(s\) but check-limit/);
165
+ });
161
166
  });
@@ -68,7 +68,7 @@ describe("lint", () => {
68
68
  const cwd = path.join(import.meta.dirname, "lint-extra-with-cli-rules");
69
69
  await cliSnapshot(["lint", "--rules", "empty-rules", "--verbose"], { cwd }, 1);
70
70
  });
71
- test("example rules: no-types, types-only, migration-only", async () => {
71
+ test("example rules: no-types, types-only, migration-only, migration-self-contained", async () => {
72
72
  const cwd = path.join(import.meta.dirname, "lint-extra-example-rules");
73
73
  await cliSnapshot(["lint"], { cwd }, 1);
74
74
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import { latestAppliedMigrationName, parseMostAppliedMigrationNames, } from "../helpers/parse-most";
3
+ describe("parseMostAppliedMigrationNames", () => {
4
+ test("parses Version 4.0.0 enhanced-migration chain", () => {
5
+ const content = `// Version: 4.0.0
6
+ {
7
+ "20250101_000000_Init" : {} -> {a : Nat; b : Text};
8
+ "20250201_000000_AddField" : (old : {a : Nat; b : Text}) -> {a : Nat; b : Text; c : Bool};
9
+ }
10
+ actor {
11
+ stable a : Nat;
12
+ stable b : Text;
13
+ stable c : Bool
14
+ };
15
+ `;
16
+ expect(parseMostAppliedMigrationNames(content)).toEqual([
17
+ "20250101_000000_Init",
18
+ "20250201_000000_AddField",
19
+ ]);
20
+ });
21
+ test("returns empty array for Version 1.0.0 (no EM chain)", () => {
22
+ expect(parseMostAppliedMigrationNames("// Version: 1.0.0\nactor { };\n")).toEqual([]);
23
+ });
24
+ test("returns empty array for Version 3.0.0 (legacy migration)", () => {
25
+ const content = `// Version: 3.0.0
26
+ actor ({
27
+ }, {
28
+ stable var field1 : Nat;
29
+ stable var field2 : Text
30
+ });
31
+ `;
32
+ expect(parseMostAppliedMigrationNames(content)).toEqual([]);
33
+ });
34
+ test("returns null when version header is missing", () => {
35
+ expect(parseMostAppliedMigrationNames("actor { };\n")).toBeNull();
36
+ });
37
+ test("returns null when Version 4.0.0 has no actor block", () => {
38
+ expect(parseMostAppliedMigrationNames('// Version: 4.0.0\n{ "Init" : {} -> {} }')).toBeNull();
39
+ });
40
+ test("latestAppliedMigrationName picks lexicographic max", () => {
41
+ expect(latestAppliedMigrationName([
42
+ "20250101_000000_Init",
43
+ "20250301_000000_AddD",
44
+ "20250201_000000_AddField",
45
+ ])).toBe("20250301_000000_AddD");
46
+ });
47
+ });
@@ -2,6 +2,7 @@ import {
2
2
  existsSync,
3
3
  mkdirSync,
4
4
  mkdtempSync,
5
+ readFileSync,
5
6
  readdirSync,
6
7
  symlinkSync,
7
8
  writeFileSync,
@@ -13,6 +14,11 @@ import { cliError } from "../error.js";
13
14
  import { getRootDir, resolveConfigPath } from "../mops.js";
14
15
  import { resolveCanisterConfigs } from "./resolve-canisters.js";
15
16
  import { Config, MigrationsConfig } from "../types.js";
17
+ import {
18
+ latestAppliedMigrationName,
19
+ migrationBasename,
20
+ parseMostAppliedMigrationNames,
21
+ } from "./parse-most.js";
16
22
 
17
23
  function stagedMigrationsDir(chainDir: string, canisterName: string): string {
18
24
  return join(dirname(chainDir), `.migrations-${canisterName}`);
@@ -234,6 +240,117 @@ export async function prepareMigrationArgs(
234
240
  };
235
241
  }
236
242
 
243
+ /**
244
+ * Local chain (+ next) files not yet recorded in the deployed `.most` baseline.
245
+ */
246
+ export function getPendingMigrationFiles(
247
+ migrations: MigrationsConfig,
248
+ canisterName: string,
249
+ appliedNames: string[],
250
+ ): string[] {
251
+ validateMigrationsConfig(migrations, canisterName);
252
+
253
+ const chainDir = resolveConfigPath(migrations.chain);
254
+ const nextDir = migrations.next
255
+ ? resolveConfigPath(migrations.next)
256
+ : undefined;
257
+ const nextFile = nextDir ? getNextMigrationFile(nextDir) : null;
258
+
259
+ const all = getMigrationFiles(chainDir);
260
+ if (nextFile) {
261
+ all.push(nextFile);
262
+ }
263
+
264
+ const highWaterMark =
265
+ appliedNames.length > 0 ? latestAppliedMigrationName(appliedNames) : "";
266
+ return all.filter((file) => migrationBasename(file) > highWaterMark);
267
+ }
268
+
269
+ /**
270
+ * Pending migrations exceed `check-limit` relative to the deployed `.most` baseline.
271
+ */
272
+ export interface CheckLimitPendingIssue {
273
+ canisterName: string;
274
+ pending: string[];
275
+ checkLimit: number;
276
+ latestApplied: string | null;
277
+ }
278
+
279
+ export function getCheckLimitPendingIssue(
280
+ migrations: MigrationsConfig | undefined,
281
+ canisterName: string,
282
+ oldMostPath: string,
283
+ ignoreCheckLimit: boolean,
284
+ baselineIsMostFile: boolean,
285
+ ): CheckLimitPendingIssue | null {
286
+ if (!migrations || ignoreCheckLimit || !baselineIsMostFile) {
287
+ return null;
288
+ }
289
+ const checkLimit = migrations["check-limit"];
290
+ if (checkLimit === undefined) {
291
+ return null;
292
+ }
293
+
294
+ let appliedNames: string[] | null;
295
+ try {
296
+ appliedNames = parseMostAppliedMigrationNames(
297
+ readFileSync(oldMostPath, "utf8"),
298
+ );
299
+ } catch {
300
+ return null;
301
+ }
302
+ if (appliedNames === null) {
303
+ return null;
304
+ }
305
+
306
+ const pending = getPendingMigrationFiles(
307
+ migrations,
308
+ canisterName,
309
+ appliedNames,
310
+ );
311
+ if (pending.length <= checkLimit) {
312
+ return null;
313
+ }
314
+
315
+ return {
316
+ canisterName,
317
+ pending,
318
+ checkLimit,
319
+ latestApplied:
320
+ appliedNames.length > 0 ? latestAppliedMigrationName(appliedNames) : null,
321
+ };
322
+ }
323
+
324
+ function formatCheckLimitPendingLines(issue: CheckLimitPendingIssue): string[] {
325
+ const { canisterName, pending, checkLimit } = issue;
326
+ return [
327
+ `Canister '${canisterName}' has ${pending.length} pending migration(s) but check-limit=${checkLimit}. ` +
328
+ `Fold all changes into the latest pending migration: ${pending[pending.length - 1]}`,
329
+ ` Pending: ${pending.join(", ")}`,
330
+ ...(issue.latestApplied
331
+ ? [` Latest already applied migration: ${issue.latestApplied}`]
332
+ : []),
333
+ ];
334
+ }
335
+
336
+ /** Warn on accidental compat pass; fail with this diagnostic when compat already failed. */
337
+ export function reportCheckLimitPendingIssue(
338
+ issue: CheckLimitPendingIssue,
339
+ compatFailed: boolean,
340
+ ): void {
341
+ const lines = formatCheckLimitPendingLines(issue);
342
+ if (compatFailed) {
343
+ cliError(
344
+ `✗ Stable compatibility check failed for canister '${issue.canisterName}': too many pending migrations for check-limit=${issue.checkLimit}\n` +
345
+ lines.join("\n"),
346
+ );
347
+ }
348
+ console.warn(chalk.yellow(`WARN: ${lines[0]}`));
349
+ for (const line of lines.slice(1)) {
350
+ console.warn(chalk.yellow(line));
351
+ }
352
+ }
353
+
237
354
  /**
238
355
  * Absolute paths of chain migration files that `mops lint` should skip,
239
356
  * mirroring the `check-limit` trimming applied to `moc` during `mops check`.
@@ -0,0 +1,32 @@
1
+ /** Enhanced-migration names recorded in a Version 4.0.0 `.most` chain block. */
2
+ export function parseMostAppliedMigrationNames(
3
+ content: string,
4
+ ): string[] | null {
5
+ const version = content.match(/^\/\/ Version: ([^\n]+)/m)?.[1]?.trim();
6
+ if (!version) {
7
+ return null;
8
+ }
9
+ if (version !== "4.0.0") {
10
+ return [];
11
+ }
12
+ // Safe: actor types in type aliases are always indented (col ≥ 2); the main actor is at col 0.
13
+ const actorIdx = content.search(/\nactor\b/);
14
+ if (actorIdx < 0) {
15
+ return null;
16
+ }
17
+ const chainBlock = content.slice(0, actorIdx);
18
+ const names: string[] = [];
19
+ for (const match of chainBlock.matchAll(/[{;]\s*"([^"]+)"\s*:/gs)) {
20
+ names.push(match[1]!);
21
+ }
22
+ return names;
23
+ }
24
+
25
+ export function migrationBasename(file: string): string {
26
+ return file.endsWith(".mo") ? file.slice(0, -3) : file;
27
+ }
28
+
29
+ /** Lexicographic max of applied migration names (matches chain file sort order). */
30
+ export function latestAppliedMigrationName(appliedNames: string[]): string {
31
+ return appliedNames.reduce((max, name) => (name > max ? name : max), "");
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.15.1",
3
+ "version": "2.15.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
@@ -8,6 +8,17 @@ exports[`check-stable compatible upgrade from .mo file 1`] = `
8
8
  }
9
9
  `;
10
10
 
11
+ exports[`check-stable fails with check-limit diagnostic when pending migrations exceed check-limit 1`] = `
12
+ {
13
+ "exitCode": 1,
14
+ "stderr": "✗ Stable compatibility check failed for canister 'backend': too many pending migrations for check-limit=1
15
+ Canister 'backend' has 2 pending migration(s) but check-limit=1. Fold all changes into the latest pending migration: 20250301_000000_AddD.mo
16
+ Pending: 20250201_000000_AddField.mo, 20250301_000000_AddD.mo
17
+ Latest already applied migration: 20250101_000000_Init",
18
+ "stdout": "",
19
+ }
20
+ `;
21
+
11
22
  exports[`check-stable incompatible upgrade from .mo file 1`] = `
12
23
  {
13
24
  "exitCode": 1,
@@ -77,7 +77,7 @@ exports[`lint [lint.extra] edge cases: pass, empty value, no-match, missing dir
77
77
  }
78
78
  `;
79
79
 
80
- exports[`lint [lint.extra] example rules: no-types, types-only, migration-only 1`] = `
80
+ exports[`lint [lint.extra] example rules: no-types, types-only, migration-only, migration-self-contained 1`] = `
81
81
  {
82
82
  "exitCode": 1,
83
83
  "stderr": " × [ERROR]: no-types
@@ -114,6 +114,34 @@ Error: Found 1 errors
114
114
  ╰────
115
115
 
116
116
  Error: Found 1 errors
117
+ × [ERROR]: migration-self-contained
118
+ ╭─[<TEST_DIR>/lint-extra-example-rules/migrations/20250101_000000_Init.mo:3:14]
119
+ 2 │ import Backend "canister:backend";
120
+ 3 │ import Types "Types";
121
+ · ───┬───
122
+ · ╰── Migration files must not import local modules (relative paths). Got: "Types"
123
+ 4 │ import TypesDot "./Types";
124
+ ╰────
125
+
126
+ × [ERROR]: migration-self-contained
127
+ ╭─[<TEST_DIR>/lint-extra-example-rules/migrations/20250101_000000_Init.mo:4:17]
128
+ 3 │ import Types "Types";
129
+ 4 │ import TypesDot "./Types";
130
+ · ────┬────
131
+ · ╰── Migration files must not import local modules (relative paths). Got: "./Types"
132
+ 5 │ import TypesParent "../src/Types";
133
+ ╰────
134
+
135
+ × [ERROR]: migration-self-contained
136
+ ╭─[<TEST_DIR>/lint-extra-example-rules/migrations/20250101_000000_Init.mo:5:20]
137
+ 4 │ import TypesDot "./Types";
138
+ 5 │ import TypesParent "../src/Types";
139
+ · ───────┬──────
140
+ · ╰── Migration files must not import local modules (relative paths). Got: "../src/Types"
141
+ 6 │
142
+ ╰────
143
+
144
+ Error: Found 3 errors
117
145
  Lint failed",
118
146
  "stdout": "",
119
147
  }
@@ -0,0 +1,8 @@
1
+ // Version: 4.0.0
2
+ {
3
+ "20250101_000000_Init" : {} -> {a : Nat; b : Text};
4
+ }
5
+ actor {
6
+ stable a : Nat;
7
+ stable b : Text
8
+ };
@@ -0,0 +1,5 @@
1
+ module {
2
+ public func migration(_ : {}) : { a : Nat; b : Text } {
3
+ { a = 42; b = "hello" };
4
+ };
5
+ };
@@ -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,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 = 1
13
+
14
+ [canisters.backend.check-stable]
15
+ path = "deployed.most"
@@ -0,0 +1,12 @@
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
+
9
+ public func check() : async () {
10
+ Prim.debugPrint(debug_show { a; b; c; d });
11
+ };
12
+ };
@@ -106,4 +106,28 @@ describe("check-stable", () => {
106
106
  expect(result.stdout).toMatch(/Stable compatibility check passed/);
107
107
  }
108
108
  }, 60_000);
109
+
110
+ test("fails with check-limit diagnostic when pending migrations exceed check-limit", async () => {
111
+ const cwd = path.join(
112
+ import.meta.dirname,
113
+ "check-stable/check-limit-warning",
114
+ );
115
+ await cliSnapshot(["check-stable"], { cwd }, 1);
116
+ });
117
+
118
+ test("does not warn when deployed baseline matches the chain", async () => {
119
+ const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
120
+ const result = await cli(["check-stable"], { cwd });
121
+ expect(result.exitCode).toBe(0);
122
+ expect(result.stderr).not.toMatch(/pending migration\(s\) but check-limit/);
123
+ });
124
+
125
+ test("--no-check-limit suppresses pending migration warning", async () => {
126
+ const cwd = path.join(
127
+ import.meta.dirname,
128
+ "check-stable/check-limit-warning",
129
+ );
130
+ const result = await cli(["check-stable", "--no-check-limit"], { cwd });
131
+ expect(result.stderr).not.toMatch(/pending migration\(s\) but check-limit/);
132
+ });
109
133
  });
@@ -195,4 +195,13 @@ describe("check", () => {
195
195
  expect(result.stdout).not.toMatch(/\.migrations-/);
196
196
  expect(result.stdout).not.toMatch(/-A=M0254/);
197
197
  });
198
+
199
+ test("--no-check-limit suppresses pending migration warning in check", async () => {
200
+ const cwd = path.join(
201
+ import.meta.dirname,
202
+ "check-stable/check-limit-warning",
203
+ );
204
+ const result = await cli(["check", "--no-check-limit"], { cwd });
205
+ expect(result.stderr).not.toMatch(/pending migration\(s\) but check-limit/);
206
+ });
198
207
  });
@@ -0,0 +1,7 @@
1
+ name = "migration-self-contained"
2
+ description = "Migration files must not import local modules (relative paths). Got: @path"
3
+ includes = ["**/migrations/**"]
4
+ query = '''
5
+ (import (text_literal) @path @error
6
+ (#match? @path "^\"[^:]*\"$"))
7
+ '''
@@ -0,0 +1,11 @@
1
+ import Map "mo:map/Map";
2
+ import Backend "canister:backend";
3
+ import Types "Types";
4
+ import TypesDot "./Types";
5
+ import TypesParent "../src/Types";
6
+
7
+ module {
8
+ public func migration(_ : {}) : { count : Nat } {
9
+ { count = 0 };
10
+ };
11
+ };
@@ -5,3 +5,4 @@ lintoko = "0.7.0"
5
5
  "src/Main.mo" = ["lint/no-types"]
6
6
  "src/Types.mo" = ["lint/types-only"]
7
7
  "src/Migration.mo" = ["lint/migration-only"]
8
+ "migrations/**" = ["lint/migration-self-contained"]
@@ -83,7 +83,7 @@ describe("lint", () => {
83
83
  );
84
84
  });
85
85
 
86
- test("example rules: no-types, types-only, migration-only", async () => {
86
+ test("example rules: no-types, types-only, migration-only, migration-self-contained", async () => {
87
87
  const cwd = path.join(import.meta.dirname, "lint-extra-example-rules");
88
88
  await cliSnapshot(["lint"], { cwd }, 1);
89
89
  });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import {
3
+ latestAppliedMigrationName,
4
+ parseMostAppliedMigrationNames,
5
+ } from "../helpers/parse-most";
6
+
7
+ describe("parseMostAppliedMigrationNames", () => {
8
+ test("parses Version 4.0.0 enhanced-migration chain", () => {
9
+ const content = `// Version: 4.0.0
10
+ {
11
+ "20250101_000000_Init" : {} -> {a : Nat; b : Text};
12
+ "20250201_000000_AddField" : (old : {a : Nat; b : Text}) -> {a : Nat; b : Text; c : Bool};
13
+ }
14
+ actor {
15
+ stable a : Nat;
16
+ stable b : Text;
17
+ stable c : Bool
18
+ };
19
+ `;
20
+ expect(parseMostAppliedMigrationNames(content)).toEqual([
21
+ "20250101_000000_Init",
22
+ "20250201_000000_AddField",
23
+ ]);
24
+ });
25
+
26
+ test("returns empty array for Version 1.0.0 (no EM chain)", () => {
27
+ expect(
28
+ parseMostAppliedMigrationNames("// Version: 1.0.0\nactor { };\n"),
29
+ ).toEqual([]);
30
+ });
31
+
32
+ test("returns empty array for Version 3.0.0 (legacy migration)", () => {
33
+ const content = `// Version: 3.0.0
34
+ actor ({
35
+ }, {
36
+ stable var field1 : Nat;
37
+ stable var field2 : Text
38
+ });
39
+ `;
40
+ expect(parseMostAppliedMigrationNames(content)).toEqual([]);
41
+ });
42
+
43
+ test("returns null when version header is missing", () => {
44
+ expect(parseMostAppliedMigrationNames("actor { };\n")).toBeNull();
45
+ });
46
+
47
+ test("returns null when Version 4.0.0 has no actor block", () => {
48
+ expect(
49
+ parseMostAppliedMigrationNames(
50
+ '// Version: 4.0.0\n{ "Init" : {} -> {} }',
51
+ ),
52
+ ).toBeNull();
53
+ });
54
+
55
+ test("latestAppliedMigrationName picks lexicographic max", () => {
56
+ expect(
57
+ latestAppliedMigrationName([
58
+ "20250101_000000_Init",
59
+ "20250301_000000_AddD",
60
+ "20250201_000000_AddField",
61
+ ]),
62
+ ).toBe("20250301_000000_AddD");
63
+ });
64
+ });