ic-mops 2.15.1 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bundle/cli.tgz +0 -0
  3. package/check-requirements.ts +53 -31
  4. package/cli.ts +37 -10
  5. package/commands/bench.ts +23 -5
  6. package/commands/check-stable.ts +20 -3
  7. package/commands/check.ts +6 -1
  8. package/commands/toolchain/index.ts +1 -3
  9. package/dist/check-requirements.js +31 -24
  10. package/dist/cli.js +19 -9
  11. package/dist/commands/bench.d.ts +2 -0
  12. package/dist/commands/bench.js +16 -3
  13. package/dist/commands/check-stable.d.ts +2 -1
  14. package/dist/commands/check-stable.js +8 -2
  15. package/dist/commands/check.js +6 -1
  16. package/dist/commands/toolchain/index.js +1 -3
  17. package/dist/helpers/get-lintoko-version.d.ts +3 -0
  18. package/dist/helpers/get-lintoko-version.js +30 -0
  19. package/dist/helpers/migrations.d.ts +16 -0
  20. package/dist/helpers/migrations.js +71 -1
  21. package/dist/helpers/parse-most.d.ts +5 -0
  22. package/dist/helpers/parse-most.js +28 -0
  23. package/dist/mops.js +2 -2
  24. package/dist/package.json +1 -1
  25. package/dist/tests/check-stable.test.js +15 -0
  26. package/dist/tests/check.test.js +5 -0
  27. package/dist/tests/lint.test.js +1 -1
  28. package/dist/tests/migrations-most.test.d.ts +1 -0
  29. package/dist/tests/migrations-most.test.js +47 -0
  30. package/dist/tests/requirements.test.d.ts +1 -0
  31. package/dist/tests/requirements.test.js +46 -0
  32. package/dist/types.d.ts +1 -0
  33. package/helpers/get-lintoko-version.ts +32 -0
  34. package/helpers/migrations.ts +117 -0
  35. package/helpers/parse-most.ts +32 -0
  36. package/mops.ts +2 -2
  37. package/package.json +1 -1
  38. package/tests/__snapshots__/check-stable.test.ts.snap +11 -0
  39. package/tests/__snapshots__/lint.test.ts.snap +29 -1
  40. package/tests/check-stable/check-limit-warning/deployed.most +8 -0
  41. package/tests/check-stable/check-limit-warning/migrations/20250101_000000_Init.mo +5 -0
  42. package/tests/check-stable/check-limit-warning/migrations/20250201_000000_AddField.mo +9 -0
  43. package/tests/check-stable/check-limit-warning/migrations/20250301_000000_AddD.mo +10 -0
  44. package/tests/check-stable/check-limit-warning/mops.toml +15 -0
  45. package/tests/check-stable/check-limit-warning/src/main.mo +12 -0
  46. package/tests/check-stable.test.ts +24 -0
  47. package/tests/check.test.ts +9 -0
  48. package/tests/lint-extra-example-rules/lint/migration-self-contained/migration-self-contained.toml +7 -0
  49. package/tests/lint-extra-example-rules/migrations/20250101_000000_Init.mo +11 -0
  50. package/tests/lint-extra-example-rules/mops.toml +1 -0
  51. package/tests/lint.test.ts +1 -1
  52. package/tests/migrations-most.test.ts +64 -0
  53. package/tests/requirements-lintoko/mops.toml +5 -0
  54. package/tests/requirements.test.ts +66 -0
  55. package/types.ts +1 -0
@@ -0,0 +1,30 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { parse } from "semver";
3
+ import { FILE_PATH_REGEX } from "../constants.js";
4
+ import { readConfig } from "../mops.js";
5
+ export function getLintokoSemVer() {
6
+ return parse(getLintokoVersion(false));
7
+ }
8
+ export function getLintokoVersion(throwOnError = false) {
9
+ let configVersion = readConfig().toolchain?.lintoko;
10
+ if (!configVersion) {
11
+ return "";
12
+ }
13
+ if (!configVersion.match(FILE_PATH_REGEX)) {
14
+ return configVersion;
15
+ }
16
+ try {
17
+ let match = execFileSync(configVersion, ["--version"])
18
+ .toString()
19
+ .trim()
20
+ .match(/lintoko ([^\s]+)/);
21
+ return match?.[1] || "";
22
+ }
23
+ catch (e) {
24
+ if (throwOnError) {
25
+ console.error(e);
26
+ throw new Error("lintoko not found");
27
+ }
28
+ return "";
29
+ }
30
+ }
@@ -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/mops.js CHANGED
@@ -177,9 +177,9 @@ export function readConfig(configFile = getClosestConfigFile()) {
177
177
  processDeps(toml["dev-dependencies"] || {});
178
178
  let config = { ...toml };
179
179
  Object.entries(config.requirements || {}).forEach(([name, value]) => {
180
- if (name === "moc") {
180
+ if (name === "moc" || name === "lintoko") {
181
181
  config.requirements = config.requirements || {};
182
- config.requirements.moc = value;
182
+ config.requirements[name] = value;
183
183
  }
184
184
  });
185
185
  return config;
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.15.1",
3
+ "version": "2.16.0",
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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "@jest/globals";
2
+ import { cp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import path from "path";
4
+ import { bytesToHex } from "@noble/hashes/utils";
5
+ import { sha256 } from "@noble/hashes/sha256";
6
+ import { cli } from "./helpers";
7
+ describe("requirements", () => {
8
+ const fixtureDir = path.join(import.meta.dirname, "requirements-lintoko");
9
+ let tempDir;
10
+ beforeEach(async () => {
11
+ tempDir = path.join(import.meta.dirname, `_tmp_requirements_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
12
+ await cp(fixtureDir, tempDir, { recursive: true });
13
+ const depDir = path.join(tempDir, ".mops", "my-pkg@0.1.0");
14
+ await mkdir(depDir, { recursive: true });
15
+ await writeFile(path.join(depDir, "mops.toml"), `[package]
16
+ name = "my-pkg"
17
+ version = "0.1.0"
18
+
19
+ [requirements]
20
+ lintoko = "0.10.0"
21
+ `);
22
+ const mopsTomlDepsHash = bytesToHex(sha256(JSON.stringify({ "my-pkg": "0.1.0" })));
23
+ await writeFile(path.join(tempDir, "mops.lock"), JSON.stringify({
24
+ version: 3,
25
+ mopsTomlDepsHash,
26
+ deps: { "my-pkg": "0.1.0" },
27
+ hashes: {
28
+ "my-pkg@0.1.0": {
29
+ "mops.toml": "0000000000000000000000000000000000000000000000000000000000000000",
30
+ },
31
+ },
32
+ }));
33
+ });
34
+ afterEach(async () => {
35
+ await rm(tempDir, { recursive: true, force: true });
36
+ });
37
+ test("lintoko requirement warns when installed version is too old", async () => {
38
+ const result = await cli(["toolchain", "use", "lintoko", "0.7.0"], {
39
+ cwd: tempDir,
40
+ });
41
+ expect(result.exitCode).toBe(0);
42
+ expect(result.stdout).toMatch(/lintoko version does not meet the requirements of my-pkg@0\.1\.0/);
43
+ expect(result.stdout).toMatch(/Required: >= 0\.10\.0/);
44
+ expect(result.stdout).toMatch(/Installed:\s+0\.7\.0/);
45
+ });
46
+ });
package/dist/types.d.ts CHANGED
@@ -71,5 +71,6 @@ export type Toolchain = {
71
71
  export type Tool = "moc" | "wasmtime" | "pocket-ic" | "lintoko";
72
72
  export type Requirements = {
73
73
  moc?: string;
74
+ lintoko?: string;
74
75
  };
75
76
  export type TestMode = "interpreter" | "wasi" | "replica";
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { type SemVer, parse } from "semver";
3
+ import { FILE_PATH_REGEX } from "../constants.js";
4
+ import { readConfig } from "../mops.js";
5
+
6
+ export function getLintokoSemVer(): SemVer | null {
7
+ return parse(getLintokoVersion(false));
8
+ }
9
+
10
+ export function getLintokoVersion(throwOnError = false): string {
11
+ let configVersion = readConfig().toolchain?.lintoko;
12
+ if (!configVersion) {
13
+ return "";
14
+ }
15
+ if (!configVersion.match(FILE_PATH_REGEX)) {
16
+ return configVersion;
17
+ }
18
+
19
+ try {
20
+ let match = execFileSync(configVersion, ["--version"])
21
+ .toString()
22
+ .trim()
23
+ .match(/lintoko ([^\s]+)/);
24
+ return match?.[1] || "";
25
+ } catch (e) {
26
+ if (throwOnError) {
27
+ console.error(e);
28
+ throw new Error("lintoko not found");
29
+ }
30
+ return "";
31
+ }
32
+ }
@@ -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/mops.ts CHANGED
@@ -205,9 +205,9 @@ export function readConfig(configFile = getClosestConfigFile()): Config {
205
205
  let config: Config = { ...toml };
206
206
 
207
207
  Object.entries(config.requirements || {}).forEach(([name, value]) => {
208
- if (name === "moc") {
208
+ if (name === "moc" || name === "lintoko") {
209
209
  config.requirements = config.requirements || {};
210
- config.requirements.moc = value;
210
+ config.requirements[name] = value;
211
211
  }
212
212
  });
213
213
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.15.1",
3
+ "version": "2.16.0",
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
+ };