ic-mops 2.13.0 → 2.13.1
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 +3 -0
- package/bundle/cli.tgz +0 -0
- package/commands/lint.ts +40 -7
- package/dist/commands/lint.js +19 -1
- package/dist/helpers/migrations.d.ts +8 -1
- package/dist/helpers/migrations.js +53 -21
- package/dist/package.json +1 -1
- package/dist/tests/lint.test.js +63 -1
- package/helpers/migrations.ts +84 -31
- package/package.json +1 -1
- package/tests/lint.test.ts +85 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Next
|
|
4
4
|
|
|
5
|
+
## 2.13.1
|
|
6
|
+
- `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.
|
|
7
|
+
|
|
5
8
|
## 2.13.0
|
|
6
9
|
- 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
10
|
|
package/bundle/cli.tgz
CHANGED
|
Binary file
|
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(
|
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>;
|
|
@@ -4,6 +4,7 @@ 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
|
|
@@ -82,25 +83,39 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
|
|
|
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");
|
|
@@ -111,15 +126,13 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
|
|
|
111
126
|
await rm(tempDir, { recursive: true, force: true });
|
|
112
127
|
mkdirSync(tempDir, { recursive: true });
|
|
113
128
|
writeFileSync(join(tempDir, ".gitignore"), "*\n");
|
|
114
|
-
const
|
|
115
|
-
? allMigrations.slice(-limit)
|
|
116
|
-
: allMigrations;
|
|
117
|
-
for (const { file, dir } of filesToInclude) {
|
|
129
|
+
for (const { file, dir } of included) {
|
|
118
130
|
symlinkSync(resolve(dir, file), join(tempDir, file));
|
|
119
131
|
}
|
|
120
132
|
if (verbose) {
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
const totalCount = included.length + excludedChainFiles.length;
|
|
134
|
+
console.log(chalk.blue("migrations"), chalk.gray(`Prepared ${included.length} migration(s) for ${canisterName}` +
|
|
135
|
+
(isTrimming ? ` (trimmed from ${totalCount})` : "")));
|
|
123
136
|
}
|
|
124
137
|
const migrationArgs = [`--enhanced-migration=${tempDir}`];
|
|
125
138
|
if (isTrimming) {
|
|
@@ -132,3 +145,22 @@ export async function prepareMigrationArgs(migrations, canisterName, mode, verbo
|
|
|
132
145
|
},
|
|
133
146
|
};
|
|
134
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Absolute paths of chain migration files that `mops lint` should skip,
|
|
150
|
+
* mirroring the `check-limit` trimming applied to `moc` during `mops check`.
|
|
151
|
+
* Validates the migrations config along the way, so misconfig surfaces here
|
|
152
|
+
* just as it does in `mops check` (consistent failure across commands).
|
|
153
|
+
*/
|
|
154
|
+
export function getTrimmedMigrationFiles(config) {
|
|
155
|
+
const excluded = new Set();
|
|
156
|
+
for (const [name, canister] of Object.entries(resolveCanisterConfigs(config))) {
|
|
157
|
+
if (!canister.migrations) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const { excludedChainFiles } = resolveMigrationChain(canister.migrations, name, "check");
|
|
161
|
+
for (const f of excludedChainFiles) {
|
|
162
|
+
excluded.add(f);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return excluded;
|
|
166
|
+
}
|
package/dist/package.json
CHANGED
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
|
});
|
package/helpers/migrations.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { rm } from "node:fs/promises";
|
|
|
10
10
|
import chalk from "chalk";
|
|
11
11
|
import { cliError } from "../error.js";
|
|
12
12
|
import { getRootDir, resolveConfigPath } from "../mops.js";
|
|
13
|
-
import {
|
|
13
|
+
import { resolveCanisterConfigs } from "./resolve-canisters.js";
|
|
14
|
+
import { Config, MigrationsConfig } from "../types.js";
|
|
14
15
|
|
|
15
16
|
function stagedMigrationsDir(chainDir: string, canisterName: string): string {
|
|
16
17
|
return join(dirname(chainDir), `.migrations-${canisterName}`);
|
|
@@ -95,21 +96,29 @@ export function validateMigrationsConfig(
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
interface MigrationChain {
|
|
100
|
+
chainDir: string;
|
|
101
|
+
nextDir?: string;
|
|
102
|
+
/** Entries to pass to moc, in order, after `*-limit` trimming. */
|
|
103
|
+
included: { file: string; dir: string }[];
|
|
104
|
+
/** Absolute paths of chain files dropped by trimming (next is never dropped). */
|
|
105
|
+
excludedChainFiles: string[];
|
|
106
|
+
/** True when `*-limit` excluded any entries. */
|
|
107
|
+
isTrimming: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve the active migration chain for a canister: validate config, discover
|
|
112
|
+
* files, and apply `check-limit` / `build-limit`. Single source of truth for
|
|
113
|
+
* the trim semantics shared by `prepareMigrationArgs` (which stages `included`
|
|
114
|
+
* for moc) and `getTrimmedMigrationFiles` (which feeds `excludedChainFiles`
|
|
115
|
+
* to lint).
|
|
116
|
+
*/
|
|
117
|
+
function resolveMigrationChain(
|
|
118
|
+
migrations: MigrationsConfig,
|
|
100
119
|
canisterName: string,
|
|
101
120
|
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
|
-
|
|
121
|
+
): MigrationChain {
|
|
113
122
|
validateMigrationsConfig(migrations, canisterName);
|
|
114
123
|
|
|
115
124
|
const chainDir = resolveConfigPath(migrations.chain);
|
|
@@ -126,25 +135,46 @@ export async function prepareMigrationArgs(
|
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
const chainFiles = getMigrationFiles(chainDir);
|
|
129
|
-
|
|
130
138
|
if (nextFile) {
|
|
131
139
|
validateNextMigrationOrder(chainFiles, nextFile);
|
|
132
140
|
}
|
|
133
141
|
|
|
134
|
-
// Treat chain + next as one virtual merged list
|
|
135
|
-
|
|
136
|
-
const allMigrations: MigrationEntry[] = chainFiles.map((f) => ({
|
|
142
|
+
// Treat chain + next as one virtual merged list; `next` is always last.
|
|
143
|
+
const all: { file: string; dir: string }[] = chainFiles.map((f) => ({
|
|
137
144
|
file: f,
|
|
138
145
|
dir: chainDir,
|
|
139
146
|
}));
|
|
140
147
|
if (nextFile && nextDir) {
|
|
141
|
-
|
|
148
|
+
all.push({ file: nextFile, dir: nextDir });
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
const limit =
|
|
145
152
|
mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
|
|
146
|
-
const isTrimming = limit !== undefined && limit <
|
|
147
|
-
const
|
|
153
|
+
const isTrimming = limit !== undefined && limit < all.length;
|
|
154
|
+
const included = isTrimming ? all.slice(-limit!) : all;
|
|
155
|
+
// Dropped entries are always a chain-only prefix (next sorts last).
|
|
156
|
+
const excludedChainFiles = all
|
|
157
|
+
.slice(0, all.length - included.length)
|
|
158
|
+
.map((e) => resolve(e.dir, e.file));
|
|
159
|
+
|
|
160
|
+
return { chainDir, nextDir, included, excludedChainFiles, isTrimming };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function prepareMigrationArgs(
|
|
164
|
+
migrations: MigrationsConfig | undefined,
|
|
165
|
+
canisterName: string,
|
|
166
|
+
mode: "check" | "build",
|
|
167
|
+
verbose?: boolean,
|
|
168
|
+
): Promise<MigrationArgsResult> {
|
|
169
|
+
if (!migrations) {
|
|
170
|
+
return { migrationArgs: [], cleanup: async () => {} };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { chainDir, nextDir, included, excludedChainFiles, isTrimming } =
|
|
174
|
+
resolveMigrationChain(migrations, canisterName, mode);
|
|
175
|
+
|
|
176
|
+
const hasNext = included.some((e) => e.dir === nextDir);
|
|
177
|
+
const needsTempDir = hasNext || isTrimming;
|
|
148
178
|
|
|
149
179
|
if (!needsTempDir) {
|
|
150
180
|
return {
|
|
@@ -153,9 +183,9 @@ export async function prepareMigrationArgs(
|
|
|
153
183
|
};
|
|
154
184
|
}
|
|
155
185
|
|
|
156
|
-
// Shortcut:
|
|
157
|
-
//
|
|
158
|
-
if (
|
|
186
|
+
// Shortcut: only the pending next migration is included → point moc at
|
|
187
|
+
// next-migration/ so diagnostics use the real path instead of the temp dir.
|
|
188
|
+
if (nextDir && included.length === 1 && included[0]!.dir === nextDir) {
|
|
159
189
|
const migrationArgs = [`--enhanced-migration=${nextDir}`];
|
|
160
190
|
if (isTrimming) {
|
|
161
191
|
migrationArgs.push("-A=M0254");
|
|
@@ -168,20 +198,17 @@ export async function prepareMigrationArgs(
|
|
|
168
198
|
mkdirSync(tempDir, { recursive: true });
|
|
169
199
|
writeFileSync(join(tempDir, ".gitignore"), "*\n");
|
|
170
200
|
|
|
171
|
-
const
|
|
172
|
-
? allMigrations.slice(-limit)
|
|
173
|
-
: allMigrations;
|
|
174
|
-
|
|
175
|
-
for (const { file, dir } of filesToInclude) {
|
|
201
|
+
for (const { file, dir } of included) {
|
|
176
202
|
symlinkSync(resolve(dir, file), join(tempDir, file));
|
|
177
203
|
}
|
|
178
204
|
|
|
179
205
|
if (verbose) {
|
|
206
|
+
const totalCount = included.length + excludedChainFiles.length;
|
|
180
207
|
console.log(
|
|
181
208
|
chalk.blue("migrations"),
|
|
182
209
|
chalk.gray(
|
|
183
|
-
`Prepared ${
|
|
184
|
-
(isTrimming ? ` (trimmed from ${
|
|
210
|
+
`Prepared ${included.length} migration(s) for ${canisterName}` +
|
|
211
|
+
(isTrimming ? ` (trimmed from ${totalCount})` : ""),
|
|
185
212
|
),
|
|
186
213
|
);
|
|
187
214
|
}
|
|
@@ -198,3 +225,29 @@ export async function prepareMigrationArgs(
|
|
|
198
225
|
},
|
|
199
226
|
};
|
|
200
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Absolute paths of chain migration files that `mops lint` should skip,
|
|
231
|
+
* mirroring the `check-limit` trimming applied to `moc` during `mops check`.
|
|
232
|
+
* Validates the migrations config along the way, so misconfig surfaces here
|
|
233
|
+
* just as it does in `mops check` (consistent failure across commands).
|
|
234
|
+
*/
|
|
235
|
+
export function getTrimmedMigrationFiles(config: Config): Set<string> {
|
|
236
|
+
const excluded = new Set<string>();
|
|
237
|
+
for (const [name, canister] of Object.entries(
|
|
238
|
+
resolveCanisterConfigs(config),
|
|
239
|
+
)) {
|
|
240
|
+
if (!canister.migrations) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const { excludedChainFiles } = resolveMigrationChain(
|
|
244
|
+
canister.migrations,
|
|
245
|
+
name,
|
|
246
|
+
"check",
|
|
247
|
+
);
|
|
248
|
+
for (const f of excludedChainFiles) {
|
|
249
|
+
excluded.add(f);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return excluded;
|
|
253
|
+
}
|
package/package.json
CHANGED
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
|
});
|