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
@@ -1,18 +1,22 @@
1
1
  import semver from "semver";
2
- import { PocketIc, PocketIcServer } from "pic-ic";
3
- import { PocketIc as PocketIcModern, PocketIcServer as PocketIcServerModern, } from "pic-js-mops";
4
2
  import { readConfig } from "../mops.js";
5
3
  function isLegacy() {
6
4
  let version = readConfig().toolchain?.["pocket-ic"];
7
5
  return !!version && !!semver.valid(version) && semver.lt(version, "9.0.0");
8
6
  }
9
7
  export async function startPocketIc(options) {
8
+ // Imported lazily so commands that never start a replica don't load the
9
+ // PocketIC client. `pic-js-mops` ships ESM without `type: module`, which a
10
+ // static import fails to resolve under tsx (local dev); a dynamic import
11
+ // resolves it on every platform.
10
12
  if (isLegacy()) {
13
+ const { PocketIc, PocketIcServer } = await import("pic-ic");
11
14
  let server = await PocketIcServer.start(options);
12
15
  let client = await PocketIc.create(server.getUrl());
13
16
  return { server, client };
14
17
  }
15
- let server = await PocketIcServerModern.start(options);
16
- let client = await PocketIcModern.create(server.getUrl());
18
+ const { PocketIc, PocketIcServer } = await import("pic-js-mops");
19
+ let server = await PocketIcServer.start(options);
20
+ let client = await PocketIc.create(server.getUrl());
17
21
  return { server, client };
18
22
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.15.0",
3
+ "version": "2.15.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -1,5 +1,5 @@
1
1
  import { beforeAll, describe, expect, test } from "@jest/globals";
2
- import { cpSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { chmodSync, cpSync, readdirSync, readFileSync, unlinkSync, } from "node:fs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import path from "path";
5
5
  import { lock } from "proper-lockfile";
@@ -19,7 +19,9 @@ describe("check --fix", () => {
19
19
  const diagnosticFlags = [warningFlags, "--error-format=json"];
20
20
  beforeAll(() => {
21
21
  for (const file of readdirSync(runDir).filter((f) => f.endsWith(".mo"))) {
22
- unlinkSync(path.join(runDir, file));
22
+ const p = path.join(runDir, file);
23
+ chmodSync(p, 0o644);
24
+ unlinkSync(p);
23
25
  }
24
26
  });
25
27
  function copyFixture(file) {
@@ -86,6 +88,16 @@ describe("check --fix", () => {
86
88
  expect(fixResult.stdout).toContain("1 fix applied");
87
89
  expect(readFileSync(runFilePath, "utf-8")).not.toContain("<Nat>");
88
90
  });
91
+ test("read-only file is skipped, not crashed", async () => {
92
+ const runFilePath = copyFixture("M0223.mo");
93
+ const before = readFileSync(runFilePath, "utf-8");
94
+ chmodSync(runFilePath, 0o444);
95
+ const result = await cli(["check", runFilePath, "--fix", "--", warningFlags], { cwd: fixDir });
96
+ expect(result.exitCode).toBe(0);
97
+ expect(result.stderr).toMatch(/Skipped read-only file/);
98
+ // File left untouched since the fix couldn't be written.
99
+ expect(readFileSync(runFilePath, "utf-8")).toBe(before);
100
+ });
89
101
  test("verbose", async () => {
90
102
  const result = await cli(["check", "Ok.mo", "--fix", "--verbose"], {
91
103
  cwd: fixDir,
@@ -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
+ });
@@ -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
 
@@ -186,6 +187,9 @@ export async function autofixMotoko(
186
187
  mocArgs: string[],
187
188
  ): Promise<AutofixResult | null> {
188
189
  const fixedFilesCodes = new Map<string, string[]>();
190
+ // Frozen migration files are often chmod'd read-only; warn once and skip
191
+ // them rather than aborting the whole run on EACCES/EPERM.
192
+ const readOnlySkipped = new Set<string>();
189
193
 
190
194
  for (let iteration = 0; iteration < MAX_FIX_ITERATIONS; iteration++) {
191
195
  const fixesByFile = new Map<string, DiagnosticFix[]>();
@@ -212,6 +216,10 @@ export async function autofixMotoko(
212
216
  let progress = false;
213
217
 
214
218
  for (const [file, fixes] of fixesByFile) {
219
+ if (readOnlySkipped.has(file)) {
220
+ continue;
221
+ }
222
+
215
223
  const original = await readFile(file, "utf-8");
216
224
  const { text: result, appliedCodes } = applyDiagnosticFixes(
217
225
  original,
@@ -222,7 +230,20 @@ export async function autofixMotoko(
222
230
  continue;
223
231
  }
224
232
 
225
- await writeFile(file, result, "utf-8");
233
+ try {
234
+ await writeFile(file, result, "utf-8");
235
+ } catch (err: any) {
236
+ if (err?.code === "EACCES" || err?.code === "EPERM") {
237
+ readOnlySkipped.add(file);
238
+ console.warn(
239
+ chalk.yellow(
240
+ `Skipped read-only file ${relative(process.cwd(), file)} (${appliedCodes.length} fix(es) not applied)`,
241
+ ),
242
+ );
243
+ continue;
244
+ }
245
+ throw err;
246
+ }
226
247
  progress = true;
227
248
 
228
249
  const existing = fixedFilesCodes.get(file) ?? [];
@@ -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
+ }
@@ -1,9 +1,9 @@
1
1
  import semver from "semver";
2
- import { PocketIc, PocketIcServer } from "pic-ic";
3
- import {
2
+ import type { PocketIc, PocketIcServer } from "pic-ic";
3
+ import type {
4
4
  PocketIc as PocketIcModern,
5
5
  PocketIcServer as PocketIcServerModern,
6
- type StartServerOptions,
6
+ StartServerOptions,
7
7
  } from "pic-js-mops";
8
8
  import { readConfig } from "../mops.js";
9
9
 
@@ -20,13 +20,19 @@ function isLegacy(): boolean {
20
20
  export async function startPocketIc(
21
21
  options: StartServerOptions,
22
22
  ): Promise<{ server: AnyPocketIcServer; client: AnyPocketIc }> {
23
+ // Imported lazily so commands that never start a replica don't load the
24
+ // PocketIC client. `pic-js-mops` ships ESM without `type: module`, which a
25
+ // static import fails to resolve under tsx (local dev); a dynamic import
26
+ // resolves it on every platform.
23
27
  if (isLegacy()) {
28
+ const { PocketIc, PocketIcServer } = await import("pic-ic");
24
29
  let server = await PocketIcServer.start(options);
25
30
  let client = await PocketIc.create(server.getUrl());
26
31
  return { server, client };
27
32
  }
28
33
 
29
- let server = await PocketIcServerModern.start(options);
30
- let client = await PocketIcModern.create(server.getUrl());
34
+ const { PocketIc, PocketIcServer } = await import("pic-js-mops");
35
+ let server = await PocketIcServer.start(options);
36
+ let client = await PocketIc.create(server.getUrl());
31
37
  return { server, client };
32
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.15.0",
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
  }
@@ -1,5 +1,11 @@
1
1
  import { beforeAll, describe, expect, test } from "@jest/globals";
2
- import { cpSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import {
3
+ chmodSync,
4
+ cpSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ unlinkSync,
8
+ } from "node:fs";
3
9
  import { mkdir, writeFile } from "node:fs/promises";
4
10
  import path from "path";
5
11
  import { lock } from "proper-lockfile";
@@ -22,7 +28,9 @@ describe("check --fix", () => {
22
28
 
23
29
  beforeAll(() => {
24
30
  for (const file of readdirSync(runDir).filter((f) => f.endsWith(".mo"))) {
25
- unlinkSync(path.join(runDir, file));
31
+ const p = path.join(runDir, file);
32
+ chmodSync(p, 0o644);
33
+ unlinkSync(p);
26
34
  }
27
35
  });
28
36
 
@@ -132,6 +140,22 @@ describe("check --fix", () => {
132
140
  expect(readFileSync(runFilePath, "utf-8")).not.toContain("<Nat>");
133
141
  });
134
142
 
143
+ test("read-only file is skipped, not crashed", async () => {
144
+ const runFilePath = copyFixture("M0223.mo");
145
+ const before = readFileSync(runFilePath, "utf-8");
146
+ chmodSync(runFilePath, 0o444);
147
+
148
+ const result = await cli(
149
+ ["check", runFilePath, "--fix", "--", warningFlags],
150
+ { cwd: fixDir },
151
+ );
152
+
153
+ expect(result.exitCode).toBe(0);
154
+ expect(result.stderr).toMatch(/Skipped read-only file/);
155
+ // File left untouched since the fix couldn't be written.
156
+ expect(readFileSync(runFilePath, "utf-8")).toBe(before);
157
+ });
158
+
135
159
  test("verbose", async () => {
136
160
  const result = await cli(["check", "Ok.mo", "--fix", "--verbose"], {
137
161
  cwd: fixDir,
@@ -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
+ '''