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.
- package/CHANGELOG.md +10 -0
- package/bundle/cli.tgz +0 -0
- package/check-requirements.ts +53 -31
- package/cli.ts +37 -10
- package/commands/bench.ts +23 -5
- package/commands/check-stable.ts +20 -3
- package/commands/check.ts +6 -1
- package/commands/toolchain/index.ts +1 -3
- package/dist/check-requirements.js +31 -24
- package/dist/cli.js +19 -9
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +16 -3
- 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/commands/toolchain/index.js +1 -3
- package/dist/helpers/get-lintoko-version.d.ts +3 -0
- package/dist/helpers/get-lintoko-version.js +30 -0
- 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/mops.js +2 -2
- package/dist/package.json +1 -1
- 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/dist/tests/requirements.test.d.ts +1 -0
- package/dist/tests/requirements.test.js +46 -0
- package/dist/types.d.ts +1 -0
- package/helpers/get-lintoko-version.ts +32 -0
- package/helpers/migrations.ts +117 -0
- package/helpers/parse-most.ts +32 -0
- package/mops.ts +2 -2
- 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-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
- package/tests/requirements-lintoko/mops.toml +5 -0
- package/tests/requirements.test.ts +66 -0
- 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
|
|
182
|
+
config.requirements[name] = value;
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
185
|
return config;
|
package/dist/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
@@ -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
|
+
}
|
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
|
+
}
|
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
|
|
210
|
+
config.requirements[name] = value;
|
|
211
211
|
}
|
|
212
212
|
});
|
|
213
213
|
|
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
|
}
|