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.
- package/CHANGELOG.md +12 -0
- package/bundle/cli.tgz +0 -0
- package/cli.ts +31 -11
- package/commands/bench-replica.ts +2 -1
- package/commands/bench.ts +32 -9
- package/commands/check-stable.ts +20 -3
- package/commands/check.ts +6 -1
- package/dist/cli.js +18 -10
- package/dist/commands/bench-replica.js +3 -1
- package/dist/commands/bench.js +21 -4
- package/dist/commands/check-stable.d.ts +2 -1
- package/dist/commands/check-stable.js +8 -2
- package/dist/commands/check.js +6 -1
- package/dist/helpers/autofix-motoko.js +19 -2
- package/dist/helpers/migrations.d.ts +16 -0
- package/dist/helpers/migrations.js +71 -1
- package/dist/helpers/parse-most.d.ts +5 -0
- package/dist/helpers/parse-most.js +28 -0
- package/dist/helpers/pocket-ic-client.d.ts +2 -2
- package/dist/helpers/pocket-ic-client.js +8 -4
- package/dist/package.json +1 -1
- package/dist/tests/check-fix.test.js +14 -2
- package/dist/tests/check-stable.test.js +15 -0
- package/dist/tests/check.test.js +5 -0
- package/dist/tests/lint.test.js +1 -1
- package/dist/tests/migrations-most.test.d.ts +1 -0
- package/dist/tests/migrations-most.test.js +47 -0
- package/helpers/autofix-motoko.ts +23 -2
- package/helpers/migrations.ts +117 -0
- package/helpers/parse-most.ts +32 -0
- package/helpers/pocket-ic-client.ts +11 -5
- package/package.json +1 -1
- package/tests/__snapshots__/check-stable.test.ts.snap +11 -0
- package/tests/__snapshots__/lint.test.ts.snap +29 -1
- package/tests/check-fix.test.ts +26 -2
- package/tests/check-stable/check-limit-warning/deployed.most +8 -0
- package/tests/check-stable/check-limit-warning/migrations/20250101_000000_Init.mo +5 -0
- package/tests/check-stable/check-limit-warning/migrations/20250201_000000_AddField.mo +9 -0
- package/tests/check-stable/check-limit-warning/migrations/20250301_000000_AddD.mo +10 -0
- package/tests/check-stable/check-limit-warning/mops.toml +15 -0
- package/tests/check-stable/check-limit-warning/src/main.mo +12 -0
- package/tests/check-stable.test.ts +24 -0
- package/tests/check.test.ts +9 -0
- package/tests/lint-extra-example-rules/lint/migration-self-contained/migration-self-contained.toml +7 -0
- package/tests/lint-extra-example-rules/migrations/20250101_000000_Init.mo +11 -0
- package/tests/lint-extra-example-rules/mops.toml +1 -0
- package/tests/lint.test.ts +1 -1
- 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
|
-
|
|
16
|
-
let
|
|
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,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
|
-
|
|
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
|
});
|
package/dist/tests/check.test.js
CHANGED
|
@@ -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
|
});
|
package/dist/tests/lint.test.js
CHANGED
|
@@ -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
|
-
|
|
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) ?? [];
|
package/helpers/migrations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
let
|
|
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
|
@@ -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
|
}
|
package/tests/check-fix.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, test } from "@jest/globals";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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,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"
|
|
@@ -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
|
});
|
package/tests/check.test.ts
CHANGED
|
@@ -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
|
});
|