ic-mops 2.13.0 → 2.13.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 +9 -0
- package/bundle/cli.tgz +0 -0
- package/commands/check-stable.ts +27 -23
- package/commands/check.ts +0 -1
- package/commands/lint.ts +40 -7
- package/dist/commands/check-stable.d.ts +0 -1
- package/dist/commands/check-stable.js +21 -18
- package/dist/commands/check.js +0 -1
- package/dist/commands/lint.js +19 -1
- package/dist/helpers/migrations.d.ts +8 -1
- package/dist/helpers/migrations.js +60 -26
- package/dist/package.json +1 -1
- package/dist/tests/check-stable.test.js +17 -0
- package/dist/tests/check.test.js +4 -3
- package/dist/tests/helpers.js +5 -1
- package/dist/tests/lint.test.js +63 -1
- package/dist/tests/migrate.test.js +2 -2
- package/dist/types.d.ts +1 -0
- package/helpers/migrations.ts +91 -35
- package/package.json +1 -1
- package/tests/__snapshots__/migrate.test.ts.snap +1 -2
- package/tests/check-stable/migrations-chain/deployed.most +14 -0
- package/tests/check-stable/migrations-chain/migrations/20250101_000000_Init.mo +8 -0
- package/tests/check-stable/migrations-chain/migrations/20250201_000000_AddField.mo +9 -0
- package/tests/check-stable/migrations-chain/migrations/20250301_000000_AddD.mo +10 -0
- package/tests/check-stable/migrations-chain/migrations/20250401_000000_AddE.mo +11 -0
- package/tests/check-stable/migrations-chain/mops.toml +15 -0
- package/tests/check-stable/migrations-chain/src/main.mo +13 -0
- package/tests/check-stable.test.ts +20 -0
- package/tests/check.test.ts +4 -3
- package/tests/helpers.ts +5 -1
- package/tests/lint.test.ts +85 -1
- package/tests/migrate.test.ts +2 -2
- package/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Next
|
|
4
4
|
|
|
5
|
+
## 2.13.2
|
|
6
|
+
- Fix race conditions when two `mops` processes run on the same project (e.g. an editor watcher and `caffeine check --fix`, or back-to-back invocations). `mops check-stable` used a shared `.mops/.check-stable/` scratch dir and `mops check`/`build`/`check-stable` used a shared `<parent>/.migrations-<canister>/` staging dir; concurrent runs would clobber each other and surface as misleading errors like `.mops/.check-stable/new.most: No such file or directory` or `EEXIST: file already exists, symlink ...`. Both directories are now per-invocation (created via `mkdtemp` and removed when the command finishes).
|
|
7
|
+
- Deprecate `skipIfMissing` in `[canisters.<name>.check-stable]`. Behavior is unchanged for now, but `mops check`/`check-stable` print a warning when it is set. For initial deployments, commit a `.most` file at the configured `path` containing an empty actor (`// Version: 1.0.0\nactor { };`) instead — the stable check then runs against an empty baseline.
|
|
8
|
+
- Drop the "you may need a migration" hint after a failed stable compatibility check in `mops check`/`check-stable`. The hint guessed at whether the user needed a new migration or a fix to an existing one, and `moc`'s underlying compatibility error already links to the migration docs.
|
|
9
|
+
- The missing-chain-directory error from `mops check`/`build`/`check-stable` now points at adding a `.mo` file to the `chain` directory instead of running the experimental `mops migrate new <Name>` command.
|
|
10
|
+
|
|
11
|
+
## 2.13.1
|
|
12
|
+
- `mops lint` now honors `[canisters.<name>.migrations].check-limit`, skipping trimmed chain migrations so projects with large migration histories lint as fast as they type-check. Pass an explicit filter (`mops lint <name>`) to opt back in for a one-off lint of a trimmed file.
|
|
13
|
+
|
|
5
14
|
## 2.13.0
|
|
6
15
|
- Fix `mops update` and `mops outdated` jumping across major versions (or pre-1.0 minor versions) — they are now caret-bound by default, matching `cargo update`. For example, `core = "2.0.0"` now updates within `2.x.y` instead of jumping to a future `3.0.0`. Use `--major` to opt into cross-major updates.
|
|
7
16
|
|
package/bundle/cli.tgz
CHANGED
|
Binary file
|
package/commands/check-stable.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
|
|
3
3
|
import { rm } from "node:fs/promises";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { execa } from "execa";
|
|
@@ -17,7 +17,10 @@ import {
|
|
|
17
17
|
import { sourcesArgs } from "./sources.js";
|
|
18
18
|
import { toolchain } from "./toolchain/index.js";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Per-invocation scratch dir lives under `.mops/`; `mkdtempSync` makes it unique so
|
|
21
|
+
// concurrent `mops` processes don't clobber each other's `old.most`/`new.most`.
|
|
22
|
+
const CHECK_STABLE_PARENT = ".mops";
|
|
23
|
+
const CHECK_STABLE_PREFIX = ".check-stable-";
|
|
21
24
|
|
|
22
25
|
export interface CheckStableOptions {
|
|
23
26
|
verbose: boolean;
|
|
@@ -39,15 +42,25 @@ export function resolveStablePath(
|
|
|
39
42
|
return null;
|
|
40
43
|
}
|
|
41
44
|
const stablePath = resolveConfigPath(stableConfig.path);
|
|
45
|
+
if (stableConfig.skipIfMissing) {
|
|
46
|
+
console.warn(
|
|
47
|
+
chalk.yellow(
|
|
48
|
+
`WARN: \`skipIfMissing\` in [canisters.${canisterName}.check-stable] is deprecated. ` +
|
|
49
|
+
`Instead, create ${stableConfig.path} with an empty actor:\n` +
|
|
50
|
+
" // Version: 1.0.0\n" +
|
|
51
|
+
" actor { };",
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
42
55
|
if (!existsSync(stablePath)) {
|
|
43
56
|
if (stableConfig.skipIfMissing) {
|
|
44
57
|
return null;
|
|
45
58
|
}
|
|
46
59
|
cliError(
|
|
47
60
|
`Deployed file not found: ${stablePath} (canister '${canisterName}')\n` +
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
61
|
+
`Create ${stableConfig.path} with an empty actor to enable the check:\n` +
|
|
62
|
+
" // Version: 1.0.0\n" +
|
|
63
|
+
" actor { };",
|
|
51
64
|
);
|
|
52
65
|
}
|
|
53
66
|
return stablePath;
|
|
@@ -88,7 +101,6 @@ export async function checkStable(
|
|
|
88
101
|
globalMocArgs,
|
|
89
102
|
canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
|
|
90
103
|
options,
|
|
91
|
-
hasMigrations: !!canister.migrations,
|
|
92
104
|
});
|
|
93
105
|
} finally {
|
|
94
106
|
await migration.cleanup();
|
|
@@ -131,7 +143,6 @@ export async function checkStable(
|
|
|
131
143
|
canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
|
|
132
144
|
sources,
|
|
133
145
|
options,
|
|
134
|
-
hasMigrations: !!canister.migrations,
|
|
135
146
|
});
|
|
136
147
|
} finally {
|
|
137
148
|
await migration.cleanup();
|
|
@@ -159,7 +170,6 @@ export interface RunStableCheckParams {
|
|
|
159
170
|
canisterArgs: string[];
|
|
160
171
|
sources?: string[];
|
|
161
172
|
options?: Partial<CheckStableOptions>;
|
|
162
|
-
hasMigrations?: boolean;
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
export async function runStableCheck(
|
|
@@ -182,15 +192,17 @@ export async function runStableCheck(
|
|
|
182
192
|
cliError(`File not found: ${oldFile}`);
|
|
183
193
|
}
|
|
184
194
|
|
|
185
|
-
|
|
186
|
-
|
|
195
|
+
mkdirSync(CHECK_STABLE_PARENT, { recursive: true });
|
|
196
|
+
const scratchDir = mkdtempSync(
|
|
197
|
+
join(CHECK_STABLE_PARENT, CHECK_STABLE_PREFIX),
|
|
198
|
+
);
|
|
187
199
|
try {
|
|
188
200
|
const oldMostPath = isOldMostFile
|
|
189
201
|
? oldFile
|
|
190
202
|
: await generateStableTypes(
|
|
191
203
|
mocPath,
|
|
192
204
|
oldFile,
|
|
193
|
-
join(
|
|
205
|
+
join(scratchDir, "old.most"),
|
|
194
206
|
sources,
|
|
195
207
|
globalMocArgs,
|
|
196
208
|
canisterArgs,
|
|
@@ -200,7 +212,7 @@ export async function runStableCheck(
|
|
|
200
212
|
const newMostPath = await generateStableTypes(
|
|
201
213
|
mocPath,
|
|
202
214
|
canisterMain,
|
|
203
|
-
join(
|
|
215
|
+
join(scratchDir, "new.most"),
|
|
204
216
|
sources,
|
|
205
217
|
globalMocArgs,
|
|
206
218
|
canisterArgs,
|
|
@@ -228,13 +240,6 @@ export async function runStableCheck(
|
|
|
228
240
|
if (result.stderr) {
|
|
229
241
|
console.error(result.stderr);
|
|
230
242
|
}
|
|
231
|
-
if (params.hasMigrations) {
|
|
232
|
-
console.error(
|
|
233
|
-
chalk.yellow(
|
|
234
|
-
"Hint: You may need a migration. Run `mops migrate new <Name>` to create one.",
|
|
235
|
-
),
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
243
|
cliError(
|
|
239
244
|
`✗ Stable compatibility check failed for canister '${canisterName}'`,
|
|
240
245
|
);
|
|
@@ -246,7 +251,7 @@ export async function runStableCheck(
|
|
|
246
251
|
),
|
|
247
252
|
);
|
|
248
253
|
} finally {
|
|
249
|
-
await rm(
|
|
254
|
+
await rm(scratchDir, { recursive: true, force: true });
|
|
250
255
|
}
|
|
251
256
|
}
|
|
252
257
|
|
|
@@ -259,8 +264,7 @@ async function generateStableTypes(
|
|
|
259
264
|
canisterArgs: string[],
|
|
260
265
|
options: Partial<CheckStableOptions>,
|
|
261
266
|
): Promise<string> {
|
|
262
|
-
const
|
|
263
|
-
const wasmPath = join(CHECK_STABLE_DIR, base + ".wasm");
|
|
267
|
+
const wasmPath = outputPath.replace(/\.most$/, ".wasm");
|
|
264
268
|
const args = [
|
|
265
269
|
"--stable-types",
|
|
266
270
|
"-o",
|
package/commands/check.ts
CHANGED
|
@@ -216,7 +216,6 @@ async function checkCanisters(
|
|
|
216
216
|
canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
|
|
217
217
|
sources,
|
|
218
218
|
options: { verbose: options.verbose, extraArgs: options.extraArgs },
|
|
219
|
-
hasMigrations: !!canister.migrations,
|
|
220
219
|
});
|
|
221
220
|
}
|
|
222
221
|
} finally {
|
package/commands/lint.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { toolchain } from "./toolchain/index.js";
|
|
|
15
15
|
import { MOTOKO_GLOB_CONFIG } from "../constants.js";
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { Config } from "../types.js";
|
|
18
|
+
import { getTrimmedMigrationFiles } from "../helpers/migrations.js";
|
|
18
19
|
|
|
19
20
|
async function resolveDepRules(
|
|
20
21
|
config: Config,
|
|
@@ -128,6 +129,17 @@ function buildCommonArgs(
|
|
|
128
129
|
return args;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
function dropTrimmedMigrations(
|
|
133
|
+
files: string[],
|
|
134
|
+
rootDir: string,
|
|
135
|
+
excluded: Set<string>,
|
|
136
|
+
): string[] {
|
|
137
|
+
if (excluded.size === 0) {
|
|
138
|
+
return files;
|
|
139
|
+
}
|
|
140
|
+
return files.filter((f) => !excluded.has(path.resolve(rootDir, f)));
|
|
141
|
+
}
|
|
142
|
+
|
|
131
143
|
async function runLintoko(
|
|
132
144
|
lintokoBinPath: string,
|
|
133
145
|
rootDir: string,
|
|
@@ -169,6 +181,11 @@ export async function lint(
|
|
|
169
181
|
? await toolchain.bin("lintoko")
|
|
170
182
|
: "lintoko";
|
|
171
183
|
|
|
184
|
+
const isExplicit = !!filter || !!(options.files && options.files.length > 0);
|
|
185
|
+
const trimmedMigrations = isExplicit
|
|
186
|
+
? new Set<string>()
|
|
187
|
+
: getTrimmedMigrationFiles(config);
|
|
188
|
+
|
|
172
189
|
let filesToLint: string[];
|
|
173
190
|
if (options.files && options.files.length > 0) {
|
|
174
191
|
filesToLint = options.files;
|
|
@@ -185,6 +202,20 @@ export async function lint(
|
|
|
185
202
|
: "No .mo files found in the project",
|
|
186
203
|
);
|
|
187
204
|
}
|
|
205
|
+
const before = filesToLint.length;
|
|
206
|
+
filesToLint = dropTrimmedMigrations(
|
|
207
|
+
filesToLint,
|
|
208
|
+
rootDir,
|
|
209
|
+
trimmedMigrations,
|
|
210
|
+
);
|
|
211
|
+
if (options.verbose && before !== filesToLint.length) {
|
|
212
|
+
console.log(
|
|
213
|
+
chalk.blue("lint"),
|
|
214
|
+
chalk.gray(
|
|
215
|
+
`Trimmed ${before - filesToLint.length} migration file(s) (check-limit)`,
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
188
219
|
}
|
|
189
220
|
|
|
190
221
|
const commonArgs = buildCommonArgs(options, config);
|
|
@@ -198,13 +229,9 @@ export async function lint(
|
|
|
198
229
|
rules.forEach((rule) => baseArgs.push("--rules", rule));
|
|
199
230
|
baseArgs.push(...filesToLint);
|
|
200
231
|
|
|
201
|
-
let failed =
|
|
202
|
-
|
|
203
|
-
rootDir,
|
|
204
|
-
baseArgs,
|
|
205
|
-
options,
|
|
206
|
-
"base",
|
|
207
|
-
));
|
|
232
|
+
let failed =
|
|
233
|
+
filesToLint.length > 0 &&
|
|
234
|
+
!(await runLintoko(lintokoBinPath, rootDir, baseArgs, options, "base"));
|
|
208
235
|
|
|
209
236
|
// --- extra runs ---
|
|
210
237
|
const extraEntries = config.lint?.extra;
|
|
@@ -243,6 +270,12 @@ export async function lint(
|
|
|
243
270
|
);
|
|
244
271
|
}
|
|
245
272
|
|
|
273
|
+
matchedFiles = dropTrimmedMigrations(
|
|
274
|
+
matchedFiles,
|
|
275
|
+
rootDir,
|
|
276
|
+
trimmedMigrations,
|
|
277
|
+
);
|
|
278
|
+
|
|
246
279
|
if (matchedFiles.length === 0) {
|
|
247
280
|
console.warn(
|
|
248
281
|
chalk.yellow(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
|
|
3
3
|
import { rm } from "node:fs/promises";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { execa } from "execa";
|
|
@@ -9,7 +9,10 @@ import { getGlobalMocArgs, readConfig, resolveConfigPath } from "../mops.js";
|
|
|
9
9
|
import { filterCanisters, looksLikeFile, resolveCanisterConfigs, resolveSingleCanister, validateCanisterArgs, } from "../helpers/resolve-canisters.js";
|
|
10
10
|
import { sourcesArgs } from "./sources.js";
|
|
11
11
|
import { toolchain } from "./toolchain/index.js";
|
|
12
|
-
|
|
12
|
+
// Per-invocation scratch dir lives under `.mops/`; `mkdtempSync` makes it unique so
|
|
13
|
+
// concurrent `mops` processes don't clobber each other's `old.most`/`new.most`.
|
|
14
|
+
const CHECK_STABLE_PARENT = ".mops";
|
|
15
|
+
const CHECK_STABLE_PREFIX = ".check-stable-";
|
|
13
16
|
export function resolveStablePath(canister, canisterName, options) {
|
|
14
17
|
const stableConfig = canister["check-stable"];
|
|
15
18
|
if (!stableConfig) {
|
|
@@ -19,14 +22,20 @@ export function resolveStablePath(canister, canisterName, options) {
|
|
|
19
22
|
return null;
|
|
20
23
|
}
|
|
21
24
|
const stablePath = resolveConfigPath(stableConfig.path);
|
|
25
|
+
if (stableConfig.skipIfMissing) {
|
|
26
|
+
console.warn(chalk.yellow(`WARN: \`skipIfMissing\` in [canisters.${canisterName}.check-stable] is deprecated. ` +
|
|
27
|
+
`Instead, create ${stableConfig.path} with an empty actor:\n` +
|
|
28
|
+
" // Version: 1.0.0\n" +
|
|
29
|
+
" actor { };"));
|
|
30
|
+
}
|
|
22
31
|
if (!existsSync(stablePath)) {
|
|
23
32
|
if (stableConfig.skipIfMissing) {
|
|
24
33
|
return null;
|
|
25
34
|
}
|
|
26
35
|
cliError(`Deployed file not found: ${stablePath} (canister '${canisterName}')\n` +
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
36
|
+
`Create ${stableConfig.path} with an empty actor to enable the check:\n` +
|
|
37
|
+
" // Version: 1.0.0\n" +
|
|
38
|
+
" actor { };");
|
|
30
39
|
}
|
|
31
40
|
return stablePath;
|
|
32
41
|
}
|
|
@@ -53,7 +62,6 @@ export async function checkStable(args, options = {}) {
|
|
|
53
62
|
globalMocArgs,
|
|
54
63
|
canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
|
|
55
64
|
options,
|
|
56
|
-
hasMigrations: !!canister.migrations,
|
|
57
65
|
});
|
|
58
66
|
}
|
|
59
67
|
finally {
|
|
@@ -88,7 +96,6 @@ export async function checkStable(args, options = {}) {
|
|
|
88
96
|
canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
|
|
89
97
|
sources,
|
|
90
98
|
options,
|
|
91
|
-
hasMigrations: !!canister.migrations,
|
|
92
99
|
});
|
|
93
100
|
}
|
|
94
101
|
finally {
|
|
@@ -111,13 +118,13 @@ export async function runStableCheck(params) {
|
|
|
111
118
|
if (!existsSync(oldFile)) {
|
|
112
119
|
cliError(`File not found: ${oldFile}`);
|
|
113
120
|
}
|
|
114
|
-
|
|
115
|
-
|
|
121
|
+
mkdirSync(CHECK_STABLE_PARENT, { recursive: true });
|
|
122
|
+
const scratchDir = mkdtempSync(join(CHECK_STABLE_PARENT, CHECK_STABLE_PREFIX));
|
|
116
123
|
try {
|
|
117
124
|
const oldMostPath = isOldMostFile
|
|
118
125
|
? oldFile
|
|
119
|
-
: await generateStableTypes(mocPath, oldFile, join(
|
|
120
|
-
const newMostPath = await generateStableTypes(mocPath, canisterMain, join(
|
|
126
|
+
: await generateStableTypes(mocPath, oldFile, join(scratchDir, "old.most"), sources, globalMocArgs, canisterArgs, options);
|
|
127
|
+
const newMostPath = await generateStableTypes(mocPath, canisterMain, join(scratchDir, "new.most"), sources, globalMocArgs, canisterArgs, options);
|
|
121
128
|
if (options.verbose) {
|
|
122
129
|
console.log(chalk.blue("check-stable"), chalk.gray(`Comparing ${oldMostPath} ↔ ${newMostPath}`));
|
|
123
130
|
}
|
|
@@ -133,20 +140,16 @@ export async function runStableCheck(params) {
|
|
|
133
140
|
if (result.stderr) {
|
|
134
141
|
console.error(result.stderr);
|
|
135
142
|
}
|
|
136
|
-
if (params.hasMigrations) {
|
|
137
|
-
console.error(chalk.yellow("Hint: You may need a migration. Run `mops migrate new <Name>` to create one."));
|
|
138
|
-
}
|
|
139
143
|
cliError(`✗ Stable compatibility check failed for canister '${canisterName}'`);
|
|
140
144
|
}
|
|
141
145
|
console.log(chalk.green(`✓ Stable compatibility check passed for canister '${canisterName}'`));
|
|
142
146
|
}
|
|
143
147
|
finally {
|
|
144
|
-
await rm(
|
|
148
|
+
await rm(scratchDir, { recursive: true, force: true });
|
|
145
149
|
}
|
|
146
150
|
}
|
|
147
151
|
async function generateStableTypes(mocPath, moFile, outputPath, sources, globalMocArgs, canisterArgs, options) {
|
|
148
|
-
const
|
|
149
|
-
const wasmPath = join(CHECK_STABLE_DIR, base + ".wasm");
|
|
152
|
+
const wasmPath = outputPath.replace(/\.most$/, ".wasm");
|
|
150
153
|
const args = [
|
|
151
154
|
"--stable-types",
|
|
152
155
|
"-o",
|
package/dist/commands/check.js
CHANGED
|
@@ -137,7 +137,6 @@ async function checkCanisters(config, canisters, options) {
|
|
|
137
137
|
canisterArgs: [...migration.migrationArgs, ...(canister.args ?? [])],
|
|
138
138
|
sources,
|
|
139
139
|
options: { verbose: options.verbose, extraArgs: options.extraArgs },
|
|
140
|
-
hasMigrations: !!canister.migrations,
|
|
141
140
|
});
|
|
142
141
|
}
|
|
143
142
|
}
|
package/dist/commands/lint.js
CHANGED
|
@@ -8,6 +8,7 @@ import { resolvePackages } from "../resolve-packages.js";
|
|
|
8
8
|
import { toolchain } from "./toolchain/index.js";
|
|
9
9
|
import { MOTOKO_GLOB_CONFIG } from "../constants.js";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
|
+
import { getTrimmedMigrationFiles } from "../helpers/migrations.js";
|
|
11
12
|
async function resolveDepRules(config, rootDir) {
|
|
12
13
|
const ext = config.lint?.extends;
|
|
13
14
|
if (!ext) {
|
|
@@ -83,6 +84,12 @@ function buildCommonArgs(options, config) {
|
|
|
83
84
|
}
|
|
84
85
|
return args;
|
|
85
86
|
}
|
|
87
|
+
function dropTrimmedMigrations(files, rootDir, excluded) {
|
|
88
|
+
if (excluded.size === 0) {
|
|
89
|
+
return files;
|
|
90
|
+
}
|
|
91
|
+
return files.filter((f) => !excluded.has(path.resolve(rootDir, f)));
|
|
92
|
+
}
|
|
86
93
|
async function runLintoko(lintokoBinPath, rootDir, args, options, label) {
|
|
87
94
|
try {
|
|
88
95
|
if (options.verbose) {
|
|
@@ -107,6 +114,10 @@ export async function lint(filter, options) {
|
|
|
107
114
|
let lintokoBinPath = config.toolchain?.lintoko
|
|
108
115
|
? await toolchain.bin("lintoko")
|
|
109
116
|
: "lintoko";
|
|
117
|
+
const isExplicit = !!filter || !!(options.files && options.files.length > 0);
|
|
118
|
+
const trimmedMigrations = isExplicit
|
|
119
|
+
? new Set()
|
|
120
|
+
: getTrimmedMigrationFiles(config);
|
|
110
121
|
let filesToLint;
|
|
111
122
|
if (options.files && options.files.length > 0) {
|
|
112
123
|
filesToLint = options.files;
|
|
@@ -122,6 +133,11 @@ export async function lint(filter, options) {
|
|
|
122
133
|
? `No files found for filter '${filter}'`
|
|
123
134
|
: "No .mo files found in the project");
|
|
124
135
|
}
|
|
136
|
+
const before = filesToLint.length;
|
|
137
|
+
filesToLint = dropTrimmedMigrations(filesToLint, rootDir, trimmedMigrations);
|
|
138
|
+
if (options.verbose && before !== filesToLint.length) {
|
|
139
|
+
console.log(chalk.blue("lint"), chalk.gray(`Trimmed ${before - filesToLint.length} migration file(s) (check-limit)`));
|
|
140
|
+
}
|
|
125
141
|
}
|
|
126
142
|
const commonArgs = buildCommonArgs(options, config);
|
|
127
143
|
// --- base run ---
|
|
@@ -131,7 +147,8 @@ export async function lint(filter, options) {
|
|
|
131
147
|
: await collectLintRules(config, rootDir);
|
|
132
148
|
rules.forEach((rule) => baseArgs.push("--rules", rule));
|
|
133
149
|
baseArgs.push(...filesToLint);
|
|
134
|
-
let failed =
|
|
150
|
+
let failed = filesToLint.length > 0 &&
|
|
151
|
+
!(await runLintoko(lintokoBinPath, rootDir, baseArgs, options, "base"));
|
|
135
152
|
// --- extra runs ---
|
|
136
153
|
const extraEntries = config.lint?.extra;
|
|
137
154
|
if (extraEntries) {
|
|
@@ -156,6 +173,7 @@ export async function lint(filter, options) {
|
|
|
156
173
|
if (baseFileSet) {
|
|
157
174
|
matchedFiles = matchedFiles.filter((f) => baseFileSet.has(path.resolve(rootDir, f)));
|
|
158
175
|
}
|
|
176
|
+
matchedFiles = dropTrimmedMigrations(matchedFiles, rootDir, trimmedMigrations);
|
|
159
177
|
if (matchedFiles.length === 0) {
|
|
160
178
|
console.warn(chalk.yellow(`[lint.extra] no files matched glob '${globPattern}', skipping`));
|
|
161
179
|
continue;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MigrationsConfig } from "../types.js";
|
|
1
|
+
import { Config, MigrationsConfig } from "../types.js";
|
|
2
2
|
export interface MigrationArgsResult {
|
|
3
3
|
migrationArgs: string[];
|
|
4
4
|
cleanup: () => Promise<void>;
|
|
@@ -8,3 +8,10 @@ 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): Promise<MigrationArgsResult>;
|
|
11
|
+
/**
|
|
12
|
+
* Absolute paths of chain migration files that `mops lint` should skip,
|
|
13
|
+
* mirroring the `check-limit` trimming applied to `moc` during `mops check`.
|
|
14
|
+
* Validates the migrations config along the way, so misconfig surfaces here
|
|
15
|
+
* just as it does in `mops check` (consistent failure across commands).
|
|
16
|
+
*/
|
|
17
|
+
export declare function getTrimmedMigrationFiles(config: Config): Set<string>;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, 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
|
+
import { resolveCanisterConfigs } from "./resolve-canisters.js";
|
|
7
8
|
function stagedMigrationsDir(chainDir, canisterName) {
|
|
8
9
|
return join(dirname(chainDir), `.migrations-${canisterName}`);
|
|
9
10
|
}
|
|
@@ -60,14 +61,14 @@ export function validateMigrationsConfig(migrations, canisterName) {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the active migration chain for a canister: validate config, discover
|
|
66
|
+
* files, and apply `check-limit` / `build-limit`. Single source of truth for
|
|
67
|
+
* the trim semantics shared by `prepareMigrationArgs` (which stages `included`
|
|
68
|
+
* for moc) and `getTrimmedMigrationFiles` (which feeds `excludedChainFiles`
|
|
69
|
+
* to lint).
|
|
70
|
+
*/
|
|
71
|
+
function resolveMigrationChain(migrations, canisterName, mode) {
|
|
71
72
|
validateMigrationsConfig(migrations, canisterName);
|
|
72
73
|
const chainDir = resolveConfigPath(migrations.chain);
|
|
73
74
|
const nextDir = migrations.next
|
|
@@ -76,50 +77,64 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
|
|
|
76
77
|
const nextFile = nextDir ? getNextMigrationFile(nextDir) : null;
|
|
77
78
|
if (!existsSync(chainDir) && !nextFile) {
|
|
78
79
|
cliError(`Migration chain directory not found: ${chainDir}\n` +
|
|
79
|
-
"
|
|
80
|
+
"Create the directory and add a `.mo` migration file to initialize the chain.");
|
|
80
81
|
}
|
|
81
82
|
const chainFiles = getMigrationFiles(chainDir);
|
|
82
83
|
if (nextFile) {
|
|
83
84
|
validateNextMigrationOrder(chainFiles, nextFile);
|
|
84
85
|
}
|
|
85
|
-
|
|
86
|
+
// Treat chain + next as one virtual merged list; `next` is always last.
|
|
87
|
+
const all = chainFiles.map((f) => ({
|
|
86
88
|
file: f,
|
|
87
89
|
dir: chainDir,
|
|
88
90
|
}));
|
|
89
91
|
if (nextFile && nextDir) {
|
|
90
|
-
|
|
92
|
+
all.push({ file: nextFile, dir: nextDir });
|
|
91
93
|
}
|
|
92
94
|
const limit = mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
|
|
93
|
-
const isTrimming = limit !== undefined && limit <
|
|
94
|
-
const
|
|
95
|
+
const isTrimming = limit !== undefined && limit < all.length;
|
|
96
|
+
const included = isTrimming ? all.slice(-limit) : all;
|
|
97
|
+
// Dropped entries are always a chain-only prefix (next sorts last).
|
|
98
|
+
const excludedChainFiles = all
|
|
99
|
+
.slice(0, all.length - included.length)
|
|
100
|
+
.map((e) => resolve(e.dir, e.file));
|
|
101
|
+
return { chainDir, nextDir, included, excludedChainFiles, isTrimming };
|
|
102
|
+
}
|
|
103
|
+
export async function prepareMigrationArgs(migrations, canisterName, mode, verbose) {
|
|
104
|
+
if (!migrations) {
|
|
105
|
+
return { migrationArgs: [], cleanup: async () => { } };
|
|
106
|
+
}
|
|
107
|
+
const { chainDir, nextDir, included, excludedChainFiles, isTrimming } = resolveMigrationChain(migrations, canisterName, mode);
|
|
108
|
+
const hasNext = included.some((e) => e.dir === nextDir);
|
|
109
|
+
const needsTempDir = hasNext || isTrimming;
|
|
95
110
|
if (!needsTempDir) {
|
|
96
111
|
return {
|
|
97
112
|
migrationArgs: [`--enhanced-migration=${chainDir}`],
|
|
98
113
|
cleanup: async () => { },
|
|
99
114
|
};
|
|
100
115
|
}
|
|
101
|
-
// Shortcut:
|
|
102
|
-
//
|
|
103
|
-
if (
|
|
116
|
+
// Shortcut: only the pending next migration is included → point moc at
|
|
117
|
+
// next-migration/ so diagnostics use the real path instead of the temp dir.
|
|
118
|
+
if (nextDir && included.length === 1 && included[0].dir === nextDir) {
|
|
104
119
|
const migrationArgs = [`--enhanced-migration=${nextDir}`];
|
|
105
120
|
if (isTrimming) {
|
|
106
121
|
migrationArgs.push("-A=M0254");
|
|
107
122
|
}
|
|
108
123
|
return { migrationArgs, cleanup: async () => { } };
|
|
109
124
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
125
|
+
// Per-invocation staging dir; `mkdtempSync` makes it unique so concurrent `mops`
|
|
126
|
+
// processes don't clobber each other's symlinks. Cleaned up below in `cleanup()`.
|
|
127
|
+
const baseDir = stagedMigrationsDir(chainDir, canisterName);
|
|
128
|
+
mkdirSync(dirname(baseDir), { recursive: true });
|
|
129
|
+
const tempDir = mkdtempSync(`${baseDir}-`);
|
|
113
130
|
writeFileSync(join(tempDir, ".gitignore"), "*\n");
|
|
114
|
-
const
|
|
115
|
-
? allMigrations.slice(-limit)
|
|
116
|
-
: allMigrations;
|
|
117
|
-
for (const { file, dir } of filesToInclude) {
|
|
131
|
+
for (const { file, dir } of included) {
|
|
118
132
|
symlinkSync(resolve(dir, file), join(tempDir, file));
|
|
119
133
|
}
|
|
120
134
|
if (verbose) {
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
const totalCount = included.length + excludedChainFiles.length;
|
|
136
|
+
console.log(chalk.blue("migrations"), chalk.gray(`Prepared ${included.length} migration(s) for ${canisterName}` +
|
|
137
|
+
(isTrimming ? ` (trimmed from ${totalCount})` : "")));
|
|
123
138
|
}
|
|
124
139
|
const migrationArgs = [`--enhanced-migration=${tempDir}`];
|
|
125
140
|
if (isTrimming) {
|
|
@@ -132,3 +147,22 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
|
|
|
132
147
|
},
|
|
133
148
|
};
|
|
134
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Absolute paths of chain migration files that `mops lint` should skip,
|
|
152
|
+
* mirroring the `check-limit` trimming applied to `moc` during `mops check`.
|
|
153
|
+
* Validates the migrations config along the way, so misconfig surfaces here
|
|
154
|
+
* just as it does in `mops check` (consistent failure across commands).
|
|
155
|
+
*/
|
|
156
|
+
export function getTrimmedMigrationFiles(config) {
|
|
157
|
+
const excluded = new Set();
|
|
158
|
+
for (const [name, canister] of Object.entries(resolveCanisterConfigs(config))) {
|
|
159
|
+
if (!canister.migrations) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const { excludedChainFiles } = resolveMigrationChain(canister.migrations, name, "check");
|
|
163
|
+
for (const f of excludedChainFiles) {
|
|
164
|
+
excluded.add(f);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return excluded;
|
|
168
|
+
}
|
package/dist/package.json
CHANGED
|
@@ -77,4 +77,21 @@ describe("check-stable", () => {
|
|
|
77
77
|
expect(result.exitCode).toBe(1);
|
|
78
78
|
expect(result.stderr).toMatch(/File not found/);
|
|
79
79
|
});
|
|
80
|
+
// Regression: two concurrent `mops check-stable` runs on the same project used to clobber
|
|
81
|
+
// each other's `.mops/.check-stable/new.most` and the staged migration symlinks, surfacing
|
|
82
|
+
// as a misleading `new.most: No such file or directory` or an `EEXIST: symlink` crash.
|
|
83
|
+
test("concurrent runs do not clobber each other's scratch state", async () => {
|
|
84
|
+
const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
|
|
85
|
+
const results = await Promise.all(Array.from({ length: 10 }, () => cli(["check-stable"], { cwd })));
|
|
86
|
+
for (const result of results) {
|
|
87
|
+
expect({
|
|
88
|
+
exitCode: result.exitCode,
|
|
89
|
+
stderr: result.stderr,
|
|
90
|
+
}).toEqual({
|
|
91
|
+
exitCode: 0,
|
|
92
|
+
stderr: "",
|
|
93
|
+
});
|
|
94
|
+
expect(result.stdout).toMatch(/Stable compatibility check passed/);
|
|
95
|
+
}
|
|
96
|
+
}, 60_000);
|
|
80
97
|
});
|
package/dist/tests/check.test.js
CHANGED
|
@@ -86,18 +86,19 @@ describe("check", () => {
|
|
|
86
86
|
expect(result.exitCode).toBe(0);
|
|
87
87
|
expect(result.stdout).toMatch(/Stable compatibility check passed/);
|
|
88
88
|
});
|
|
89
|
-
test("deployed:
|
|
89
|
+
test("deployed: skips when file missing and skipIfMissing, with deprecation warning", async () => {
|
|
90
90
|
const cwd = path.join(import.meta.dirname, "check/deployed-missing-skip");
|
|
91
91
|
const result = await cli(["check"], { cwd });
|
|
92
92
|
expect(result.exitCode).toBe(0);
|
|
93
93
|
expect(result.stdout).not.toMatch(/stable/i);
|
|
94
|
+
expect(result.stderr).toMatch(/skipIfMissing.*deprecated/);
|
|
94
95
|
});
|
|
95
|
-
test("deployed: errors when file missing
|
|
96
|
+
test("deployed: errors when file missing", async () => {
|
|
96
97
|
const cwd = path.join(import.meta.dirname, "check/deployed-missing-error");
|
|
97
98
|
const result = await cli(["check"], { cwd });
|
|
98
99
|
expect(result.exitCode).toBe(1);
|
|
99
100
|
expect(result.stderr).toMatch(/Deployed file not found/);
|
|
100
|
-
expect(result.stderr).toMatch(/
|
|
101
|
+
expect(result.stderr).toMatch(/empty actor/);
|
|
101
102
|
});
|
|
102
103
|
test("--fix runs stable check after fixing", async () => {
|
|
103
104
|
const cwd = path.join(import.meta.dirname, "check/deployed-compatible");
|
package/dist/tests/helpers.js
CHANGED
|
@@ -26,7 +26,11 @@ export const normalizePaths = (text) => {
|
|
|
26
26
|
.replace(/\/[^\s"]+\/\.cache\/mops/g, "<CACHE>")
|
|
27
27
|
.replace(/\/[^\s"]+\/Library\/Caches\/mops/g, "<CACHE>")
|
|
28
28
|
.replace(/\/[^\s"[\]]+\/moc(?:-wrapper)?(?=\s|$)/g, "moc-wrapper")
|
|
29
|
-
.replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
|
|
29
|
+
.replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
|
|
30
|
+
// Per-invocation scratch / staging dirs use mkdtemp; redact the random suffix
|
|
31
|
+
// (Node's exact suffix format isn't a stable contract) so snapshots stay stable.
|
|
32
|
+
.replace(/\.mops\/\.check-stable-\w+/g, ".mops/.check-stable")
|
|
33
|
+
.replace(/(\.migrations-[\w.-]+?)-\w+(?=[/\s"]|$)/g, "$1"));
|
|
30
34
|
};
|
|
31
35
|
export const cliSnapshot = async (args, options, exitCode) => {
|
|
32
36
|
const result = await cli(args, options);
|
package/dist/tests/lint.test.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { describe, expect, test } from "@jest/globals";
|
|
1
|
+
import { describe, expect, test, afterEach } from "@jest/globals";
|
|
2
|
+
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
2
4
|
import path from "path";
|
|
3
5
|
import { cli, cliSnapshot } from "./helpers";
|
|
4
6
|
describe("lint", () => {
|
|
@@ -71,4 +73,64 @@ describe("lint", () => {
|
|
|
71
73
|
await cliSnapshot(["lint"], { cwd }, 1);
|
|
72
74
|
});
|
|
73
75
|
});
|
|
76
|
+
describe("migration trimming via check-limit", () => {
|
|
77
|
+
const migrateFixturesDir = path.join(import.meta.dirname, "migrate");
|
|
78
|
+
const tempDirs = [];
|
|
79
|
+
afterEach(async () => {
|
|
80
|
+
for (const dir of tempDirs) {
|
|
81
|
+
await rm(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
tempDirs.length = 0;
|
|
84
|
+
});
|
|
85
|
+
async function makeWithNextLintFixture(checkLimit) {
|
|
86
|
+
const dest = path.join(migrateFixturesDir, `_tmp_lint_with-next_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`);
|
|
87
|
+
await cp(path.join(migrateFixturesDir, "with-next"), dest, {
|
|
88
|
+
recursive: true,
|
|
89
|
+
});
|
|
90
|
+
tempDirs.push(dest);
|
|
91
|
+
// Empty lints/ → collectLintRules picks it up so lintoko runs cleanly
|
|
92
|
+
// (no rules → no violations → exit 0), preventing assertions from
|
|
93
|
+
// passing by coincidence on an unrelated lintoko failure.
|
|
94
|
+
await mkdir(path.join(dest, "lints"), { recursive: true });
|
|
95
|
+
let toml = readFileSync(path.join(dest, "mops.toml"), "utf-8").replace('moc = "1.5.0"', 'moc = "1.5.0"\nlintoko = "0.7.0"');
|
|
96
|
+
if (checkLimit !== undefined) {
|
|
97
|
+
toml = toml.replace('next = "next-migration"', `next = "next-migration"\ncheck-limit = ${checkLimit}`);
|
|
98
|
+
}
|
|
99
|
+
await writeFile(path.join(dest, "mops.toml"), toml);
|
|
100
|
+
return dest;
|
|
101
|
+
}
|
|
102
|
+
test("check-limit=1 trims old chain migrations from lint", async () => {
|
|
103
|
+
// with-next has 3 chain files + 1 next file. check-limit=1 keeps only
|
|
104
|
+
// the next file → 3 chain files trimmed from lint.
|
|
105
|
+
const cwd = await makeWithNextLintFixture(1);
|
|
106
|
+
const result = await cli(["lint", "--verbose"], { cwd });
|
|
107
|
+
expect(result.exitCode).toBe(0);
|
|
108
|
+
expect(result.stdout).toMatch(/Trimmed 3 migration file\(s\) \(check-limit\)/);
|
|
109
|
+
expect(result.stdout).not.toMatch(/20250101_000000_Init\.mo/);
|
|
110
|
+
expect(result.stdout).not.toMatch(/20250201_000000_AddName\.mo/);
|
|
111
|
+
expect(result.stdout).not.toMatch(/20250301_000000_AddEmail\.mo/);
|
|
112
|
+
expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
|
|
113
|
+
});
|
|
114
|
+
test("no check-limit → all migration files are linted", async () => {
|
|
115
|
+
const cwd = await makeWithNextLintFixture();
|
|
116
|
+
const result = await cli(["lint", "--verbose"], { cwd });
|
|
117
|
+
expect(result.exitCode).toBe(0);
|
|
118
|
+
expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
|
|
119
|
+
expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
|
|
120
|
+
expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
|
|
121
|
+
});
|
|
122
|
+
test("explicit filter bypasses trimming so user can target a chain file", async () => {
|
|
123
|
+
const cwd = await makeWithNextLintFixture(1);
|
|
124
|
+
const result = await cli(["lint", "Init", "--verbose"], { cwd });
|
|
125
|
+
expect(result.exitCode).toBe(0);
|
|
126
|
+
expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
|
|
127
|
+
expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
|
|
128
|
+
});
|
|
129
|
+
test("invalid check-limit fails `mops lint` (consistent with `mops check`)", async () => {
|
|
130
|
+
const cwd = await makeWithNextLintFixture(0);
|
|
131
|
+
const result = await cli(["lint"], { cwd });
|
|
132
|
+
expect(result.exitCode).toBe(1);
|
|
133
|
+
expect(result.stderr).toMatch(/check-limit must be a positive integer/);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
74
136
|
});
|
|
@@ -173,8 +173,8 @@ describe("migrate", () => {
|
|
|
173
173
|
expect(most).toMatchSnapshot();
|
|
174
174
|
});
|
|
175
175
|
});
|
|
176
|
-
describe("stable check
|
|
177
|
-
test("stable check fails
|
|
176
|
+
describe("stable check", () => {
|
|
177
|
+
test("stable check fails when deployed.most is incompatible", async () => {
|
|
178
178
|
const cwd = await makeTempFixture("basic");
|
|
179
179
|
await writeFile(path.join(cwd, "deployed.most"), "// Version: 1.0.0\nactor {\n stable var a : Nat;\n stable var name : Int\n};\n");
|
|
180
180
|
await cliSnapshot(["check"], { cwd }, 1);
|
package/dist/types.d.ts
CHANGED
package/helpers/migrations.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
existsSync,
|
|
3
3
|
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
4
5
|
readdirSync,
|
|
5
6
|
symlinkSync,
|
|
6
7
|
writeFileSync,
|
|
@@ -10,7 +11,8 @@ import { rm } from "node:fs/promises";
|
|
|
10
11
|
import chalk from "chalk";
|
|
11
12
|
import { cliError } from "../error.js";
|
|
12
13
|
import { getRootDir, resolveConfigPath } from "../mops.js";
|
|
13
|
-
import {
|
|
14
|
+
import { resolveCanisterConfigs } from "./resolve-canisters.js";
|
|
15
|
+
import { Config, MigrationsConfig } from "../types.js";
|
|
14
16
|
|
|
15
17
|
function stagedMigrationsDir(chainDir: string, canisterName: string): string {
|
|
16
18
|
return join(dirname(chainDir), `.migrations-${canisterName}`);
|
|
@@ -95,21 +97,29 @@ export function validateMigrationsConfig(
|
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
interface MigrationChain {
|
|
101
|
+
chainDir: string;
|
|
102
|
+
nextDir?: string;
|
|
103
|
+
/** Entries to pass to moc, in order, after `*-limit` trimming. */
|
|
104
|
+
included: { file: string; dir: string }[];
|
|
105
|
+
/** Absolute paths of chain files dropped by trimming (next is never dropped). */
|
|
106
|
+
excludedChainFiles: string[];
|
|
107
|
+
/** True when `*-limit` excluded any entries. */
|
|
108
|
+
isTrimming: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the active migration chain for a canister: validate config, discover
|
|
113
|
+
* files, and apply `check-limit` / `build-limit`. Single source of truth for
|
|
114
|
+
* the trim semantics shared by `prepareMigrationArgs` (which stages `included`
|
|
115
|
+
* for moc) and `getTrimmedMigrationFiles` (which feeds `excludedChainFiles`
|
|
116
|
+
* to lint).
|
|
117
|
+
*/
|
|
118
|
+
function resolveMigrationChain(
|
|
119
|
+
migrations: MigrationsConfig,
|
|
100
120
|
canisterName: string,
|
|
101
121
|
mode: "check" | "build",
|
|
102
|
-
|
|
103
|
-
): Promise<MigrationArgsResult> {
|
|
104
|
-
const noOp: MigrationArgsResult = {
|
|
105
|
-
migrationArgs: [],
|
|
106
|
-
cleanup: async () => {},
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
if (!migrations) {
|
|
110
|
-
return noOp;
|
|
111
|
-
}
|
|
112
|
-
|
|
122
|
+
): MigrationChain {
|
|
113
123
|
validateMigrationsConfig(migrations, canisterName);
|
|
114
124
|
|
|
115
125
|
const chainDir = resolveConfigPath(migrations.chain);
|
|
@@ -121,30 +131,51 @@ export async function prepareMigrationArgs(
|
|
|
121
131
|
if (!existsSync(chainDir) && !nextFile) {
|
|
122
132
|
cliError(
|
|
123
133
|
`Migration chain directory not found: ${chainDir}\n` +
|
|
124
|
-
"
|
|
134
|
+
"Create the directory and add a `.mo` migration file to initialize the chain.",
|
|
125
135
|
);
|
|
126
136
|
}
|
|
127
137
|
|
|
128
138
|
const chainFiles = getMigrationFiles(chainDir);
|
|
129
|
-
|
|
130
139
|
if (nextFile) {
|
|
131
140
|
validateNextMigrationOrder(chainFiles, nextFile);
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
// Treat chain + next as one virtual merged list
|
|
135
|
-
|
|
136
|
-
const allMigrations: MigrationEntry[] = chainFiles.map((f) => ({
|
|
143
|
+
// Treat chain + next as one virtual merged list; `next` is always last.
|
|
144
|
+
const all: { file: string; dir: string }[] = chainFiles.map((f) => ({
|
|
137
145
|
file: f,
|
|
138
146
|
dir: chainDir,
|
|
139
147
|
}));
|
|
140
148
|
if (nextFile && nextDir) {
|
|
141
|
-
|
|
149
|
+
all.push({ file: nextFile, dir: nextDir });
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
const limit =
|
|
145
153
|
mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
|
|
146
|
-
const isTrimming = limit !== undefined && limit <
|
|
147
|
-
const
|
|
154
|
+
const isTrimming = limit !== undefined && limit < all.length;
|
|
155
|
+
const included = isTrimming ? all.slice(-limit!) : all;
|
|
156
|
+
// Dropped entries are always a chain-only prefix (next sorts last).
|
|
157
|
+
const excludedChainFiles = all
|
|
158
|
+
.slice(0, all.length - included.length)
|
|
159
|
+
.map((e) => resolve(e.dir, e.file));
|
|
160
|
+
|
|
161
|
+
return { chainDir, nextDir, included, excludedChainFiles, isTrimming };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function prepareMigrationArgs(
|
|
165
|
+
migrations: MigrationsConfig | undefined,
|
|
166
|
+
canisterName: string,
|
|
167
|
+
mode: "check" | "build",
|
|
168
|
+
verbose?: boolean,
|
|
169
|
+
): Promise<MigrationArgsResult> {
|
|
170
|
+
if (!migrations) {
|
|
171
|
+
return { migrationArgs: [], cleanup: async () => {} };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const { chainDir, nextDir, included, excludedChainFiles, isTrimming } =
|
|
175
|
+
resolveMigrationChain(migrations, canisterName, mode);
|
|
176
|
+
|
|
177
|
+
const hasNext = included.some((e) => e.dir === nextDir);
|
|
178
|
+
const needsTempDir = hasNext || isTrimming;
|
|
148
179
|
|
|
149
180
|
if (!needsTempDir) {
|
|
150
181
|
return {
|
|
@@ -153,9 +184,9 @@ export async function prepareMigrationArgs(
|
|
|
153
184
|
};
|
|
154
185
|
}
|
|
155
186
|
|
|
156
|
-
// Shortcut:
|
|
157
|
-
//
|
|
158
|
-
if (
|
|
187
|
+
// Shortcut: only the pending next migration is included → point moc at
|
|
188
|
+
// next-migration/ so diagnostics use the real path instead of the temp dir.
|
|
189
|
+
if (nextDir && included.length === 1 && included[0]!.dir === nextDir) {
|
|
159
190
|
const migrationArgs = [`--enhanced-migration=${nextDir}`];
|
|
160
191
|
if (isTrimming) {
|
|
161
192
|
migrationArgs.push("-A=M0254");
|
|
@@ -163,25 +194,24 @@ export async function prepareMigrationArgs(
|
|
|
163
194
|
return { migrationArgs, cleanup: async () => {} };
|
|
164
195
|
}
|
|
165
196
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
197
|
+
// Per-invocation staging dir; `mkdtempSync` makes it unique so concurrent `mops`
|
|
198
|
+
// processes don't clobber each other's symlinks. Cleaned up below in `cleanup()`.
|
|
199
|
+
const baseDir = stagedMigrationsDir(chainDir, canisterName);
|
|
200
|
+
mkdirSync(dirname(baseDir), { recursive: true });
|
|
201
|
+
const tempDir = mkdtempSync(`${baseDir}-`);
|
|
169
202
|
writeFileSync(join(tempDir, ".gitignore"), "*\n");
|
|
170
203
|
|
|
171
|
-
const
|
|
172
|
-
? allMigrations.slice(-limit)
|
|
173
|
-
: allMigrations;
|
|
174
|
-
|
|
175
|
-
for (const { file, dir } of filesToInclude) {
|
|
204
|
+
for (const { file, dir } of included) {
|
|
176
205
|
symlinkSync(resolve(dir, file), join(tempDir, file));
|
|
177
206
|
}
|
|
178
207
|
|
|
179
208
|
if (verbose) {
|
|
209
|
+
const totalCount = included.length + excludedChainFiles.length;
|
|
180
210
|
console.log(
|
|
181
211
|
chalk.blue("migrations"),
|
|
182
212
|
chalk.gray(
|
|
183
|
-
`Prepared ${
|
|
184
|
-
(isTrimming ? ` (trimmed from ${
|
|
213
|
+
`Prepared ${included.length} migration(s) for ${canisterName}` +
|
|
214
|
+
(isTrimming ? ` (trimmed from ${totalCount})` : ""),
|
|
185
215
|
),
|
|
186
216
|
);
|
|
187
217
|
}
|
|
@@ -198,3 +228,29 @@ export async function prepareMigrationArgs(
|
|
|
198
228
|
},
|
|
199
229
|
};
|
|
200
230
|
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Absolute paths of chain migration files that `mops lint` should skip,
|
|
234
|
+
* mirroring the `check-limit` trimming applied to `moc` during `mops check`.
|
|
235
|
+
* Validates the migrations config along the way, so misconfig surfaces here
|
|
236
|
+
* just as it does in `mops check` (consistent failure across commands).
|
|
237
|
+
*/
|
|
238
|
+
export function getTrimmedMigrationFiles(config: Config): Set<string> {
|
|
239
|
+
const excluded = new Set<string>();
|
|
240
|
+
for (const [name, canister] of Object.entries(
|
|
241
|
+
resolveCanisterConfigs(config),
|
|
242
|
+
)) {
|
|
243
|
+
if (!canister.migrations) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const { excludedChainFiles } = resolveMigrationChain(
|
|
247
|
+
canister.migrations,
|
|
248
|
+
name,
|
|
249
|
+
"check",
|
|
250
|
+
);
|
|
251
|
+
for (const f of excludedChainFiles) {
|
|
252
|
+
excluded.add(f);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return excluded;
|
|
256
|
+
}
|
package/package.json
CHANGED
|
@@ -143,12 +143,11 @@ exports[`migrate migrate new creates a migration file with timestamp and templat
|
|
|
143
143
|
}
|
|
144
144
|
`;
|
|
145
145
|
|
|
146
|
-
exports[`migrate stable check
|
|
146
|
+
exports[`migrate stable check stable check fails when deployed.most is incompatible 1`] = `
|
|
147
147
|
{
|
|
148
148
|
"exitCode": 1,
|
|
149
149
|
"stderr": "(unknown location): Compatibility error [M0169], the stable variable \`a\` of the previous version cannot be implicitly discarded. The variable can only be dropped by an explicit migration function, please see https://internetcomputer.org/docs/motoko/fundamentals/actors/compatibility#explicit-migration-using-a-migration-function
|
|
150
150
|
(unknown location): Compatibility error [M0169], the stable variable \`name\` of the previous version cannot be implicitly discarded. The variable can only be dropped by an explicit migration function, please see https://internetcomputer.org/docs/motoko/fundamentals/actors/compatibility#explicit-migration-using-a-migration-function
|
|
151
|
-
Hint: You may need a migration. Run \`mops migrate new <Name>\` to create one.
|
|
152
151
|
✗ Stable compatibility check failed for canister 'backend'",
|
|
153
152
|
"stdout": "✓ backend",
|
|
154
153
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Version: 4.0.0
|
|
2
|
+
{
|
|
3
|
+
"20250101_000000_Init" : {} -> {a : Nat; b : Text};
|
|
4
|
+
"20250201_000000_AddField" : (old : {a : Nat; b : Text}) -> {a : Nat; b : Text; c : Bool};
|
|
5
|
+
"20250301_000000_AddD" : (old : {a : Nat; b : Text; c : Bool}) -> {a : Nat; b : Text; c : Bool; d : Int};
|
|
6
|
+
"20250401_000000_AddE" : (old : {a : Nat; b : Text; c : Bool; d : Int}) -> {a : Nat; b : Text; c : Bool; d : Int; e : Text}
|
|
7
|
+
}
|
|
8
|
+
actor {
|
|
9
|
+
stable a : Nat;
|
|
10
|
+
stable b : Text;
|
|
11
|
+
stable c : Bool;
|
|
12
|
+
stable d : Int;
|
|
13
|
+
stable e : Text
|
|
14
|
+
};
|
|
@@ -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 = 3
|
|
13
|
+
|
|
14
|
+
[canisters.backend.check-stable]
|
|
15
|
+
path = "deployed.most"
|
|
@@ -86,4 +86,24 @@ describe("check-stable", () => {
|
|
|
86
86
|
expect(result.exitCode).toBe(1);
|
|
87
87
|
expect(result.stderr).toMatch(/File not found/);
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
// Regression: two concurrent `mops check-stable` runs on the same project used to clobber
|
|
91
|
+
// each other's `.mops/.check-stable/new.most` and the staged migration symlinks, surfacing
|
|
92
|
+
// as a misleading `new.most: No such file or directory` or an `EEXIST: symlink` crash.
|
|
93
|
+
test("concurrent runs do not clobber each other's scratch state", async () => {
|
|
94
|
+
const cwd = path.join(import.meta.dirname, "check-stable/migrations-chain");
|
|
95
|
+
const results = await Promise.all(
|
|
96
|
+
Array.from({ length: 10 }, () => cli(["check-stable"], { cwd })),
|
|
97
|
+
);
|
|
98
|
+
for (const result of results) {
|
|
99
|
+
expect({
|
|
100
|
+
exitCode: result.exitCode,
|
|
101
|
+
stderr: result.stderr,
|
|
102
|
+
}).toEqual({
|
|
103
|
+
exitCode: 0,
|
|
104
|
+
stderr: "",
|
|
105
|
+
});
|
|
106
|
+
expect(result.stdout).toMatch(/Stable compatibility check passed/);
|
|
107
|
+
}
|
|
108
|
+
}, 60_000);
|
|
89
109
|
});
|
package/tests/check.test.ts
CHANGED
|
@@ -113,19 +113,20 @@ describe("check", () => {
|
|
|
113
113
|
expect(result.stdout).toMatch(/Stable compatibility check passed/);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
test("deployed:
|
|
116
|
+
test("deployed: skips when file missing and skipIfMissing, with deprecation warning", async () => {
|
|
117
117
|
const cwd = path.join(import.meta.dirname, "check/deployed-missing-skip");
|
|
118
118
|
const result = await cli(["check"], { cwd });
|
|
119
119
|
expect(result.exitCode).toBe(0);
|
|
120
120
|
expect(result.stdout).not.toMatch(/stable/i);
|
|
121
|
+
expect(result.stderr).toMatch(/skipIfMissing.*deprecated/);
|
|
121
122
|
});
|
|
122
123
|
|
|
123
|
-
test("deployed: errors when file missing
|
|
124
|
+
test("deployed: errors when file missing", async () => {
|
|
124
125
|
const cwd = path.join(import.meta.dirname, "check/deployed-missing-error");
|
|
125
126
|
const result = await cli(["check"], { cwd });
|
|
126
127
|
expect(result.exitCode).toBe(1);
|
|
127
128
|
expect(result.stderr).toMatch(/Deployed file not found/);
|
|
128
|
-
expect(result.stderr).toMatch(/
|
|
129
|
+
expect(result.stderr).toMatch(/empty actor/);
|
|
129
130
|
});
|
|
130
131
|
|
|
131
132
|
test("--fix runs stable check after fixing", async () => {
|
package/tests/helpers.ts
CHANGED
|
@@ -37,7 +37,11 @@ export const normalizePaths = (text: string): string => {
|
|
|
37
37
|
.replace(/\/[^\s"]+\/\.cache\/mops/g, "<CACHE>")
|
|
38
38
|
.replace(/\/[^\s"]+\/Library\/Caches\/mops/g, "<CACHE>")
|
|
39
39
|
.replace(/\/[^\s"[\]]+\/moc(?:-wrapper)?(?=\s|$)/g, "moc-wrapper")
|
|
40
|
-
.replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
|
|
40
|
+
.replace(/\/[^\s"[\]]+\.motoko\/bin\/moc/g, "moc-wrapper")
|
|
41
|
+
// Per-invocation scratch / staging dirs use mkdtemp; redact the random suffix
|
|
42
|
+
// (Node's exact suffix format isn't a stable contract) so snapshots stay stable.
|
|
43
|
+
.replace(/\.mops\/\.check-stable-\w+/g, ".mops/.check-stable")
|
|
44
|
+
.replace(/(\.migrations-[\w.-]+?)-\w+(?=[/\s"]|$)/g, "$1"),
|
|
41
45
|
);
|
|
42
46
|
};
|
|
43
47
|
|
package/tests/lint.test.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { describe, expect, test } from "@jest/globals";
|
|
1
|
+
import { describe, expect, test, afterEach } from "@jest/globals";
|
|
2
|
+
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
2
4
|
import path from "path";
|
|
3
5
|
import { cli, cliSnapshot } from "./helpers";
|
|
4
6
|
|
|
@@ -86,4 +88,86 @@ describe("lint", () => {
|
|
|
86
88
|
await cliSnapshot(["lint"], { cwd }, 1);
|
|
87
89
|
});
|
|
88
90
|
});
|
|
91
|
+
|
|
92
|
+
describe("migration trimming via check-limit", () => {
|
|
93
|
+
const migrateFixturesDir = path.join(import.meta.dirname, "migrate");
|
|
94
|
+
const tempDirs: string[] = [];
|
|
95
|
+
|
|
96
|
+
afterEach(async () => {
|
|
97
|
+
for (const dir of tempDirs) {
|
|
98
|
+
await rm(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
tempDirs.length = 0;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
async function makeWithNextLintFixture(
|
|
104
|
+
checkLimit?: number,
|
|
105
|
+
): Promise<string> {
|
|
106
|
+
const dest = path.join(
|
|
107
|
+
migrateFixturesDir,
|
|
108
|
+
`_tmp_lint_with-next_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
109
|
+
);
|
|
110
|
+
await cp(path.join(migrateFixturesDir, "with-next"), dest, {
|
|
111
|
+
recursive: true,
|
|
112
|
+
});
|
|
113
|
+
tempDirs.push(dest);
|
|
114
|
+
|
|
115
|
+
// Empty lints/ → collectLintRules picks it up so lintoko runs cleanly
|
|
116
|
+
// (no rules → no violations → exit 0), preventing assertions from
|
|
117
|
+
// passing by coincidence on an unrelated lintoko failure.
|
|
118
|
+
await mkdir(path.join(dest, "lints"), { recursive: true });
|
|
119
|
+
|
|
120
|
+
let toml = readFileSync(path.join(dest, "mops.toml"), "utf-8").replace(
|
|
121
|
+
'moc = "1.5.0"',
|
|
122
|
+
'moc = "1.5.0"\nlintoko = "0.7.0"',
|
|
123
|
+
);
|
|
124
|
+
if (checkLimit !== undefined) {
|
|
125
|
+
toml = toml.replace(
|
|
126
|
+
'next = "next-migration"',
|
|
127
|
+
`next = "next-migration"\ncheck-limit = ${checkLimit}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
await writeFile(path.join(dest, "mops.toml"), toml);
|
|
131
|
+
return dest;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("check-limit=1 trims old chain migrations from lint", async () => {
|
|
135
|
+
// with-next has 3 chain files + 1 next file. check-limit=1 keeps only
|
|
136
|
+
// the next file → 3 chain files trimmed from lint.
|
|
137
|
+
const cwd = await makeWithNextLintFixture(1);
|
|
138
|
+
const result = await cli(["lint", "--verbose"], { cwd });
|
|
139
|
+
expect(result.exitCode).toBe(0);
|
|
140
|
+
expect(result.stdout).toMatch(
|
|
141
|
+
/Trimmed 3 migration file\(s\) \(check-limit\)/,
|
|
142
|
+
);
|
|
143
|
+
expect(result.stdout).not.toMatch(/20250101_000000_Init\.mo/);
|
|
144
|
+
expect(result.stdout).not.toMatch(/20250201_000000_AddName\.mo/);
|
|
145
|
+
expect(result.stdout).not.toMatch(/20250301_000000_AddEmail\.mo/);
|
|
146
|
+
expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("no check-limit → all migration files are linted", async () => {
|
|
150
|
+
const cwd = await makeWithNextLintFixture();
|
|
151
|
+
const result = await cli(["lint", "--verbose"], { cwd });
|
|
152
|
+
expect(result.exitCode).toBe(0);
|
|
153
|
+
expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
|
|
154
|
+
expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
|
|
155
|
+
expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("explicit filter bypasses trimming so user can target a chain file", async () => {
|
|
159
|
+
const cwd = await makeWithNextLintFixture(1);
|
|
160
|
+
const result = await cli(["lint", "Init", "--verbose"], { cwd });
|
|
161
|
+
expect(result.exitCode).toBe(0);
|
|
162
|
+
expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
|
|
163
|
+
expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("invalid check-limit fails `mops lint` (consistent with `mops check`)", async () => {
|
|
167
|
+
const cwd = await makeWithNextLintFixture(0);
|
|
168
|
+
const result = await cli(["lint"], { cwd });
|
|
169
|
+
expect(result.exitCode).toBe(1);
|
|
170
|
+
expect(result.stderr).toMatch(/check-limit must be a positive integer/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
89
173
|
});
|
package/tests/migrate.test.ts
CHANGED
|
@@ -241,8 +241,8 @@ describe("migrate", () => {
|
|
|
241
241
|
});
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
-
describe("stable check
|
|
245
|
-
test("stable check fails
|
|
244
|
+
describe("stable check", () => {
|
|
245
|
+
test("stable check fails when deployed.most is incompatible", async () => {
|
|
246
246
|
const cwd = await makeTempFixture("basic");
|
|
247
247
|
await writeFile(
|
|
248
248
|
path.join(cwd, "deployed.most"),
|