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 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 = !(await runLintoko(
202
- lintokoBinPath,
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(
@@ -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 = !(await runLintoko(lintokoBinPath, rootDir, baseArgs, options, "base"));
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
- export async function prepareMigrationArgs(migrations, canisterName, mode, verbose) {
64
- const noOp = {
65
- migrationArgs: [],
66
- cleanup: async () => { },
67
- };
68
- if (!migrations) {
69
- return noOp;
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
- const allMigrations = chainFiles.map((f) => ({
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
- allMigrations.push({ file: nextFile, dir: nextDir });
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 < allMigrations.length;
94
- const needsTempDir = nextFile !== null || isTrimming;
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: when only the pending next migration is needed (empty chain or
102
- // trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
103
- if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
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 filesToInclude = isTrimming
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
- console.log(chalk.blue("migrations"), chalk.gray(`Prepared ${filesToInclude.length} migration(s) for ${canisterName}` +
122
- (isTrimming ? ` (trimmed from ${allMigrations.length})` : "")));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.13.0",
3
+ "version": "2.13.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "bin/mops.js",
@@ -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
  });
@@ -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 { MigrationsConfig } from "../types.js";
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
- export async function prepareMigrationArgs(
99
- migrations: MigrationsConfig | undefined,
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
- verbose?: boolean,
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
- type MigrationEntry = { file: string; dir: string };
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
- allMigrations.push({ file: nextFile, dir: nextDir });
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 < allMigrations.length;
147
- const needsTempDir = nextFile !== null || isTrimming;
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: when only the pending next migration is needed (empty chain or
157
- // trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
158
- if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
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 filesToInclude = isTrimming
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 ${filesToInclude.length} migration(s) for ${canisterName}` +
184
- (isTrimming ? ` (trimmed from ${allMigrations.length})` : ""),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ic-mops",
3
- "version": "2.13.0",
3
+ "version": "2.13.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mops": "dist/bin/mops.js",
@@ -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
  });