Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .agents/skills/mops-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,11 @@ Runs lintoko (also runs automatically as part of `mops check` when lintoko is in
```bash
mops lint # lint all .mo files
mops lint --fix # autofix lint issues
mops lint <name> # filter to .mo files matching <name>
```

When `[canisters.<name>.migrations].check-limit` is set, `mops lint` skips the trimmed chain migrations to match what `moc` sees during `mops check`. To lint a trimmed migration on demand, pass an explicit filter (e.g. `mops lint OldMigrationName`).

### `mops format`

```bash
Expand Down
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Mops CLI Changelog

## Next
- `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.

## 2.13.0
- 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.
Expand Down
47 changes: 40 additions & 7 deletions cli/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { toolchain } from "./toolchain/index.js";
import { MOTOKO_GLOB_CONFIG } from "../constants.js";
import { existsSync } from "node:fs";
import { Config } from "../types.js";
import { getTrimmedMigrationFiles } from "../helpers/migrations.js";

async function resolveDepRules(
config: Config,
Expand Down Expand Up @@ -128,6 +129,17 @@ function buildCommonArgs(
return args;
}

function dropTrimmedMigrations(
files: string[],
rootDir: string,
excluded: Set<string>,
): string[] {
if (excluded.size === 0) {
return files;
}
return files.filter((f) => !excluded.has(path.resolve(rootDir, f)));
}

async function runLintoko(
lintokoBinPath: string,
rootDir: string,
Expand Down Expand Up @@ -169,6 +181,11 @@ export async function lint(
? await toolchain.bin("lintoko")
: "lintoko";

const isExplicit = !!filter || !!(options.files && options.files.length > 0);
const trimmedMigrations = isExplicit
? new Set<string>()
: getTrimmedMigrationFiles(config);

let filesToLint: string[];
if (options.files && options.files.length > 0) {
filesToLint = options.files;
Expand All @@ -185,6 +202,20 @@ export async function lint(
: "No .mo files found in the project",
);
}
const before = filesToLint.length;
filesToLint = dropTrimmedMigrations(
filesToLint,
rootDir,
trimmedMigrations,
);
if (options.verbose && before !== filesToLint.length) {
console.log(
chalk.blue("lint"),
chalk.gray(
`Trimmed ${before - filesToLint.length} migration file(s) (check-limit)`,
),
);
}
}

const commonArgs = buildCommonArgs(options, config);
Expand All @@ -198,13 +229,9 @@ export async function lint(
rules.forEach((rule) => baseArgs.push("--rules", rule));
baseArgs.push(...filesToLint);

let failed = !(await runLintoko(
lintokoBinPath,
rootDir,
baseArgs,
options,
"base",
));
let failed =
filesToLint.length > 0 &&
!(await runLintoko(lintokoBinPath, rootDir, baseArgs, options, "base"));

// --- extra runs ---
const extraEntries = config.lint?.extra;
Expand Down Expand Up @@ -243,6 +270,12 @@ export async function lint(
);
}

matchedFiles = dropTrimmedMigrations(
matchedFiles,
rootDir,
trimmedMigrations,
);

if (matchedFiles.length === 0) {
console.warn(
chalk.yellow(
Expand Down
115 changes: 84 additions & 31 deletions cli/helpers/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { rm } from "node:fs/promises";
import chalk from "chalk";
import { cliError } from "../error.js";
import { getRootDir, resolveConfigPath } from "../mops.js";
import { MigrationsConfig } from "../types.js";
import { resolveCanisterConfigs } from "./resolve-canisters.js";
import { Config, MigrationsConfig } from "../types.js";

function stagedMigrationsDir(chainDir: string, canisterName: string): string {
return join(dirname(chainDir), `.migrations-${canisterName}`);
Expand Down Expand Up @@ -95,21 +96,29 @@ export function validateMigrationsConfig(
}
}

export async function prepareMigrationArgs(
migrations: MigrationsConfig | undefined,
interface MigrationChain {
chainDir: string;
nextDir?: string;
/** Entries to pass to moc, in order, after `*-limit` trimming. */
included: { file: string; dir: string }[];
/** Absolute paths of chain files dropped by trimming (next is never dropped). */
excludedChainFiles: string[];
/** True when `*-limit` excluded any entries. */
isTrimming: boolean;
}

/**
* Resolve the active migration chain for a canister: validate config, discover
* files, and apply `check-limit` / `build-limit`. Single source of truth for
* the trim semantics shared by `prepareMigrationArgs` (which stages `included`
* for moc) and `getTrimmedMigrationFiles` (which feeds `excludedChainFiles`
* to lint).
*/
function resolveMigrationChain(
migrations: MigrationsConfig,
canisterName: string,
mode: "check" | "build",
verbose?: boolean,
): Promise<MigrationArgsResult> {
const noOp: MigrationArgsResult = {
migrationArgs: [],
cleanup: async () => {},
};

if (!migrations) {
return noOp;
}

): MigrationChain {
validateMigrationsConfig(migrations, canisterName);

const chainDir = resolveConfigPath(migrations.chain);
Expand All @@ -126,25 +135,46 @@ export async function prepareMigrationArgs(
}

const chainFiles = getMigrationFiles(chainDir);

if (nextFile) {
validateNextMigrationOrder(chainFiles, nextFile);
}

// Treat chain + next as one virtual merged list
type MigrationEntry = { file: string; dir: string };
const allMigrations: MigrationEntry[] = chainFiles.map((f) => ({
// Treat chain + next as one virtual merged list; `next` is always last.
const all: { file: string; dir: string }[] = chainFiles.map((f) => ({
file: f,
dir: chainDir,
}));
if (nextFile && nextDir) {
allMigrations.push({ file: nextFile, dir: nextDir });
all.push({ file: nextFile, dir: nextDir });
}

const limit =
mode === "check" ? migrations["check-limit"] : migrations["build-limit"];
const isTrimming = limit !== undefined && limit < allMigrations.length;
const needsTempDir = nextFile !== null || isTrimming;
const isTrimming = limit !== undefined && limit < all.length;
const included = isTrimming ? all.slice(-limit!) : all;
// Dropped entries are always a chain-only prefix (next sorts last).
const excludedChainFiles = all
.slice(0, all.length - included.length)
.map((e) => resolve(e.dir, e.file));

return { chainDir, nextDir, included, excludedChainFiles, isTrimming };
}

export async function prepareMigrationArgs(
migrations: MigrationsConfig | undefined,
canisterName: string,
mode: "check" | "build",
verbose?: boolean,
): Promise<MigrationArgsResult> {
if (!migrations) {
return { migrationArgs: [], cleanup: async () => {} };
}

const { chainDir, nextDir, included, excludedChainFiles, isTrimming } =
resolveMigrationChain(migrations, canisterName, mode);

const hasNext = included.some((e) => e.dir === nextDir);
const needsTempDir = hasNext || isTrimming;

if (!needsTempDir) {
return {
Expand All @@ -153,9 +183,9 @@ export async function prepareMigrationArgs(
};
}

// Shortcut: when only the pending next migration is needed (empty chain or
// trimmed to 1), point moc at next-migration/ so diagnostics use the real path.
if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) {
// Shortcut: only the pending next migration is included → point moc at
// next-migration/ so diagnostics use the real path instead of the temp dir.
if (nextDir && included.length === 1 && included[0]!.dir === nextDir) {
const migrationArgs = [`--enhanced-migration=${nextDir}`];
if (isTrimming) {
migrationArgs.push("-A=M0254");
Expand All @@ -168,20 +198,17 @@ export async function prepareMigrationArgs(
mkdirSync(tempDir, { recursive: true });
writeFileSync(join(tempDir, ".gitignore"), "*\n");

const filesToInclude = isTrimming
? allMigrations.slice(-limit)
: allMigrations;

for (const { file, dir } of filesToInclude) {
for (const { file, dir } of included) {
symlinkSync(resolve(dir, file), join(tempDir, file));
}

if (verbose) {
const totalCount = included.length + excludedChainFiles.length;
console.log(
chalk.blue("migrations"),
chalk.gray(
`Prepared ${filesToInclude.length} migration(s) for ${canisterName}` +
(isTrimming ? ` (trimmed from ${allMigrations.length})` : ""),
`Prepared ${included.length} migration(s) for ${canisterName}` +
(isTrimming ? ` (trimmed from ${totalCount})` : ""),
),
);
}
Expand All @@ -198,3 +225,29 @@ export async function prepareMigrationArgs(
},
};
}

/**
* Absolute paths of chain migration files that `mops lint` should skip,
* mirroring the `check-limit` trimming applied to `moc` during `mops check`.
* Validates the migrations config along the way, so misconfig surfaces here
* just as it does in `mops check` (consistent failure across commands).
*/
export function getTrimmedMigrationFiles(config: Config): Set<string> {
const excluded = new Set<string>();
for (const [name, canister] of Object.entries(
resolveCanisterConfigs(config),
)) {
if (!canister.migrations) {
continue;
}
const { excludedChainFiles } = resolveMigrationChain(
canister.migrations,
name,
"check",
);
for (const f of excludedChainFiles) {
excluded.add(f);
}
}
return excluded;
}
86 changes: 85 additions & 1 deletion cli/tests/lint.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, test } from "@jest/globals";
import { describe, expect, test, afterEach } from "@jest/globals";
import { cp, mkdir, rm, writeFile } from "node:fs/promises";
import { readFileSync } from "node:fs";
import path from "path";
import { cli, cliSnapshot } from "./helpers";

Expand Down Expand Up @@ -86,4 +88,86 @@ describe("lint", () => {
await cliSnapshot(["lint"], { cwd }, 1);
});
});

describe("migration trimming via check-limit", () => {
const migrateFixturesDir = path.join(import.meta.dirname, "migrate");
const tempDirs: string[] = [];

afterEach(async () => {
for (const dir of tempDirs) {
await rm(dir, { recursive: true, force: true });
}
tempDirs.length = 0;
});

async function makeWithNextLintFixture(
checkLimit?: number,
): Promise<string> {
const dest = path.join(
migrateFixturesDir,
`_tmp_lint_with-next_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
);
await cp(path.join(migrateFixturesDir, "with-next"), dest, {
recursive: true,
});
tempDirs.push(dest);

// Empty lints/ → collectLintRules picks it up so lintoko runs cleanly
// (no rules → no violations → exit 0), preventing assertions from
// passing by coincidence on an unrelated lintoko failure.
await mkdir(path.join(dest, "lints"), { recursive: true });

let toml = readFileSync(path.join(dest, "mops.toml"), "utf-8").replace(
'moc = "1.5.0"',
'moc = "1.5.0"\nlintoko = "0.7.0"',
);
if (checkLimit !== undefined) {
toml = toml.replace(
'next = "next-migration"',
`next = "next-migration"\ncheck-limit = ${checkLimit}`,
);
}
await writeFile(path.join(dest, "mops.toml"), toml);
return dest;
}

test("check-limit=1 trims old chain migrations from lint", async () => {
// with-next has 3 chain files + 1 next file. check-limit=1 keeps only
// the next file → 3 chain files trimmed from lint.
const cwd = await makeWithNextLintFixture(1);
const result = await cli(["lint", "--verbose"], { cwd });
expect(result.exitCode).toBe(0);
expect(result.stdout).toMatch(
/Trimmed 3 migration file\(s\) \(check-limit\)/,
);
expect(result.stdout).not.toMatch(/20250101_000000_Init\.mo/);
expect(result.stdout).not.toMatch(/20250201_000000_AddName\.mo/);
expect(result.stdout).not.toMatch(/20250301_000000_AddEmail\.mo/);
expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
});

test("no check-limit → all migration files are linted", async () => {
const cwd = await makeWithNextLintFixture();
const result = await cli(["lint", "--verbose"], { cwd });
expect(result.exitCode).toBe(0);
expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
expect(result.stdout).toMatch(/20250401_000000_RenameId\.mo/);
});

test("explicit filter bypasses trimming so user can target a chain file", async () => {
const cwd = await makeWithNextLintFixture(1);
const result = await cli(["lint", "Init", "--verbose"], { cwd });
expect(result.exitCode).toBe(0);
expect(result.stdout).not.toMatch(/Trimmed \d+ migration file/);
expect(result.stdout).toMatch(/20250101_000000_Init\.mo/);
});

test("invalid check-limit fails `mops lint` (consistent with `mops check`)", async () => {
const cwd = await makeWithNextLintFixture(0);
const result = await cli(["lint"], { cwd });
expect(result.exitCode).toBe(1);
expect(result.stderr).toMatch(/check-limit must be a positive integer/);
});
});
});
2 changes: 1 addition & 1 deletion docs/docs/09-mops.toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Configure managed enhanced migration chains for a canister. When set, `mops chec
| ----------- | --------------------------------------------------------------- |
| chain | Path to the directory containing frozen migration files (required) |
| next | Path to the directory for the next pending migration (optional). Required for `mops migrate new/freeze`. Must contain 0 or 1 `.mo` files. Must share the same parent directory as `chain` |
| check-limit | Max number of migrations to pass to `moc` during `mops check` and `mops check-stable` (optional). Counts the full chain including any pending next migration |
| check-limit | Max number of migrations to pass to `moc` during `mops check` and `mops check-stable`, and to `lintoko` during `mops lint` (optional). Counts the full chain including any pending next migration |
| build-limit | Max number of migrations to pass to `moc` during `mops build` (optional). Counts the full chain including any pending next migration |

Example:
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/cli/4-dev/08-mops-migrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields.

Large migration chains increase WASM size and compilation time. Use `check-limit` and `build-limit` to trim the chain:

- **`check-limit`** — only the last N migrations are included during `mops check` and `mops check-stable`. Set to `1` for fastest type-checking.
- **`check-limit`** — only the last N migrations are included during `mops check`, `mops check-stable`, and `mops lint`. Set to `1` for fastest type-checking and linting. Pass an explicit filter (`mops lint <name>`) or file path to lint a trimmed migration on demand.
- **`build-limit`** — only the last N migrations are included during `mops build`. Set higher (e.g. `100`) so the deployed WASM can apply multiple pending migrations.

The limits count the full virtual chain (frozen + pending next migration). This means `mops build` produces identical results whether a migration is still pending or already frozen.
Expand Down
Loading