From 2dfcfa685556c8737b299452d82c49aff17f4f30 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 11:22:00 +0200 Subject: [PATCH 1/8] docs(migrate): note that moc reports staged paths in chain diagnostics When `[migrations].next` is set or `check-limit`/`build-limit` trims the chain, mops stages the active chain into `.migrations-/` and passes that directory to moc. Diagnostics for chain files therefore reference the staged path (e.g. `.migrations-backend/2025...AddEmail.mo`) rather than the original `migrations/...` path. Editor jump-to-error still works because the staged files are symlinks, but the path in raw error text was a recurring source of confusion. Document this in the mops.toml reference, the `mops migrate` page, and the mops-cli skill, and add a snapshot test that pins the current behavior so a future fix (e.g. rewriting paths in moc stderr) is an intentional snapshot update. Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 ++ cli/tests/__snapshots__/migrate.test.ts.snap | 12 ++++++++++++ cli/tests/migrate.test.ts | 14 ++++++++++++++ docs/docs/09-mops.toml.md | 2 ++ docs/docs/cli/4-dev/08-mops-migrate.md | 2 ++ 5 files changed, 32 insertions(+) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 230f4703..e069f132 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -146,6 +146,8 @@ When `[canisters..migrations]` is configured, `mops check`, `mops build`, `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders via relative paths (e.g. shared types in `src/backend/types/`); mops stages the active chain into `/.migrations-/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`. +When staging is active (a `next` migration is present or trimming applies), `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged files are symlinks, so editor jump-to-error still works. + Typical workflow: make a breaking change → `mops check` fails with a hint → `mops migrate new Name` → edit migration → `mops check` passes → `mops build` → deploy → `mops migrate freeze`. ### `mops remove ` diff --git a/cli/tests/__snapshots__/migrate.test.ts.snap b/cli/tests/__snapshots__/migrate.test.ts.snap index e9cddc77..1e00ca87 100644 --- a/cli/tests/__snapshots__/migrate.test.ts.snap +++ b/cli/tests/__snapshots__/migrate.test.ts.snap @@ -85,6 +85,18 @@ check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most } `; +exports[`migrate check error inside a chain migration reports its file location 1`] = ` +{ + "exitCode": 1, + "stderr": ".migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050], literal of type + Nat +does not have expected type + Text +✗ Check failed for canister backend (exit code: 1)", + "stdout": "", +} +`; + exports[`migrate migrate freeze moves the file from next to chain 1`] = ` { "exitCode": 0, diff --git a/cli/tests/migrate.test.ts b/cli/tests/migrate.test.ts index a53171d4..b6d11a9f 100644 --- a/cli/tests/migrate.test.ts +++ b/cli/tests/migrate.test.ts @@ -154,6 +154,20 @@ describe("migrate", () => { await patchMigrations(cwd, "check-limit = 2"); await cliSnapshot(["check", "--verbose"], { cwd }, 0); }); + + test("error inside a chain migration reports its file location", async () => { + const cwd = await makeTempFixture("with-next"); + await writeFile( + path.join(cwd, "migrations", "20250301_000000_AddEmail.mo"), + 'import State "../types/State";\n' + + "module {\n" + + " public func migration(old : State.V2) : State.V3 {\n" + + " { old with email = 42 };\n" + + " };\n" + + "};\n", + ); + await cliSnapshot(["check"], { cwd }, 1); + }); }); describe("build", () => { diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 951c17ff..84283826 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -167,6 +167,8 @@ When `[migrations]` is configured, do not add `--enhanced-migration` to `[canist :::note When a `next` migration exists or chain trimming is active, mops stages the active chain into `/.migrations-/` for compilation. This keeps the staged files at the same depth as the originals so relative imports (e.g. a shared `types/` folder next to `chain` and `next`) resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. + +When this staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`) rather than the original `migrations/...` path. The staged files are symlinks to the originals, so editor "jump to error" works correctly — but raw error text in CI logs and terminal output contains the staged path. ::: Shorthand — when only the entrypoint is needed: diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index 52266502..2eac0329 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -61,6 +61,8 @@ build-limit = 100 `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders (e.g. a shared `types/` folder) using relative paths — mops stages the active chain into `/.migrations-/` for compilation, preserving the depth of the originals so relative imports resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. +When staging is active (any time `next` is present or `check-limit`/`build-limit` trims the chain), `moc` reports diagnostics for chain files against the staged path — e.g. `.migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050] ...`, not `migrations/20250301_000000_AddEmail.mo`. The staged files are symlinks to the originals, so clicking the path in an editor jumps to the real source file. + See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. ## Typical workflow From 35780190bc04b53f0020978a91567c10fe65bdac Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 11:49:44 +0200 Subject: [PATCH 2/8] =?UTF-8?q?docs(migrate):=20correct=20staged-path=20no?= =?UTF-8?q?te=20=E2=80=94=20temp=20dir=20is=20cleaned=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor jump-to-error does not work on the printed path: the staged directory is removed in a `finally` block before the process exits, so by the time the user reads the error, the file is gone. Tell users to open the same filename under `chain` (or `next` for the pending migration) instead. Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 +- docs/docs/09-mops.toml.md | 2 +- docs/docs/cli/4-dev/08-mops-migrate.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index e069f132..89cb262f 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -146,7 +146,7 @@ When `[canisters..migrations]` is configured, `mops check`, `mops build`, `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders via relative paths (e.g. shared types in `src/backend/types/`); mops stages the active chain into `/.migrations-/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`. -When staging is active (a `next` migration is present or trimming applies), `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged files are symlinks, so editor jump-to-error still works. +When staging is active (a `next` migration is present or trimming applies), `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged directory is removed at the end of the command, so the printed path will not open in an editor — look up the same filename under `chain` (or `next` for the pending migration). Typical workflow: make a breaking change → `mops check` fails with a hint → `mops migrate new Name` → edit migration → `mops check` passes → `mops build` → deploy → `mops migrate freeze`. diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 84283826..48eddfcc 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -168,7 +168,7 @@ When `[migrations]` is configured, do not add `--enhanced-migration` to `[canist :::note When a `next` migration exists or chain trimming is active, mops stages the active chain into `/.migrations-/` for compilation. This keeps the staged files at the same depth as the originals so relative imports (e.g. a shared `types/` folder next to `chain` and `next`) resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. -When this staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`) rather than the original `migrations/...` path. The staged files are symlinks to the originals, so editor "jump to error" works correctly — but raw error text in CI logs and terminal output contains the staged path. +When this staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`) rather than the original `migrations/...` path. The staged directory is removed when the command finishes, so editor "jump to error" on the printed path will not find the file — the actual source is at the corresponding `/.mo` (or `/.mo` for the pending one). ::: Shorthand — when only the entrypoint is needed: diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index 2eac0329..24619839 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -61,7 +61,7 @@ build-limit = 100 `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders (e.g. a shared `types/` folder) using relative paths — mops stages the active chain into `/.migrations-/` for compilation, preserving the depth of the originals so relative imports resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. -When staging is active (any time `next` is present or `check-limit`/`build-limit` trims the chain), `moc` reports diagnostics for chain files against the staged path — e.g. `.migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050] ...`, not `migrations/20250301_000000_AddEmail.mo`. The staged files are symlinks to the originals, so clicking the path in an editor jumps to the real source file. +When staging is active (any time `next` is present or `check-limit`/`build-limit` trims the chain), `moc` reports diagnostics for chain files against the staged path — e.g. `.migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050] ...`, not `migrations/20250301_000000_AddEmail.mo`. The staged directory is deleted when the command finishes, so the printed path will not resolve in your editor — open the matching file under `chain` (or `next`, if it is the pending migration) instead. See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. From 901a30f0a508a1467c86676b7b5df28cad23de55 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 13:52:24 +0200 Subject: [PATCH 3/8] fix(migrate): skip staging when window is exactly the pending next migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staged migration directory only ever existed because moc takes a single `--enhanced-migration=` and we sometimes need to merge files from `chain/` and `next-migration/`. When the active window is exactly one file from `next-migration/` (the common dev-loop case `check-limit = 1` with a pending next, or no frozen chain yet), there is no merge to do — moc can be pointed at `next-migration/` directly. Errors then reference the real path the user is editing instead of a staged copy that gets `rm -rf`-ed before the user reads the message. Refactored `prepareMigrationArgs` to fold the previous early-return (no staging) and the new shortcut into a single `realDir` decision, removing duplicated arg-building. Same behavior for every other trimming/staging case. Updated docs and CHANGELOG; added a snapshot test that pins the real-path behavior. Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 +- cli/CHANGELOG.md | 1 + cli/helpers/migrations.ts | 61 +++++++++----------- cli/tests/__snapshots__/migrate.test.ts.snap | 12 ++++ cli/tests/migrate.test.ts | 17 ++++++ docs/docs/09-mops.toml.md | 2 + docs/docs/cli/4-dev/08-mops-migrate.md | 4 +- 7 files changed, 62 insertions(+), 37 deletions(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index 89cb262f..f7c15519 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -146,7 +146,7 @@ When `[canisters..migrations]` is configured, `mops check`, `mops build`, `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders via relative paths (e.g. shared types in `src/backend/types/`); mops stages the active chain into `/.migrations-/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`. -When staging is active (a `next` migration is present or trimming applies), `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged directory is removed at the end of the command, so the printed path will not open in an editor — look up the same filename under `chain` (or `next` for the pending migration). +When staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged directory is removed at the end of the command, so the printed path will not open in an editor — look up the same filename under `chain` (or `next` for the pending migration). The dev-loop case (`check-limit = 1` with a pending `next`) skips staging and reports the real `next-migration/.mo` path. Typical workflow: make a breaking change → `mops check` fails with a hint → `mops migrate new Name` → edit migration → `mops check` passes → `mops build` → deploy → `mops migrate freeze`. diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index a0f0ce2b..153fde82 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,6 +1,7 @@ # Mops CLI Changelog ## Next +- `mops check`/`build`/`check-stable` skip migration staging when the active window is exactly the pending `next` migration (typically `check-limit = 1` with a pending next, or no frozen chain yet). moc is pointed at `next-migration/` directly, so diagnostics reference the real file path you are editing instead of a staged copy that gets cleaned up before you read the error. ## 2.12.0 - Migration staging directory moved from `.mops/.migrations//` to `/.migrations-/`, so migration files can import shared modules from sibling folders (e.g. a `types/` folder next to `migrations/`) — relative imports now resolve to the same target whether moc reads the original chain dir or the staged one. The staged dir self-stamps a `.gitignore` so it doesn't pollute `git status`; `mops init` now also adds `.migrations-*/` to the project `.gitignore` diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts index cb7af07c..70929f65 100644 --- a/cli/helpers/migrations.ts +++ b/cli/helpers/migrations.ts @@ -101,13 +101,8 @@ export async function prepareMigrationArgs( mode: "check" | "build", verbose?: boolean, ): Promise { - const noOp: MigrationArgsResult = { - migrationArgs: [], - cleanup: async () => {}, - }; - if (!migrations) { - return noOp; + return { migrationArgs: [], cleanup: async () => {} }; } validateMigrationsConfig(migrations, canisterName); @@ -126,29 +121,35 @@ 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) => ({ - file: f, - dir: chainDir, - })); - if (nextFile && nextDir) { - allMigrations.push({ file: nextFile, dir: nextDir }); - } + // Virtual merged list: chain files (in chainDir) followed by the pending next file (in nextDir) + const all = [ + ...chainFiles.map((file) => ({ file, dir: chainDir })), + ...(nextFile && nextDir ? [{ 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; - - if (!needsTempDir) { + const isTrimming = limit !== undefined && limit < all.length; + const selected = isTrimming ? all.slice(-limit) : all; + const trimFlag = isTrimming ? ["-A=M0254"] : []; + + // Skip staging when the selection is exactly the contents of one real + // directory: the whole chain (no trimming, no next), or the single pending + // next migration. moc then reports diagnostics against the real path the + // user is editing instead of a staged copy that gets cleaned up. + const realDir = + !isTrimming && !nextFile + ? chainDir + : selected.length === 1 && selected[0]!.dir === nextDir + ? nextDir + : null; + if (realDir) { return { - migrationArgs: [`--enhanced-migration=${chainDir}`], + migrationArgs: [`--enhanced-migration=${realDir}`, ...trimFlag], cleanup: async () => {}, }; } @@ -157,12 +158,7 @@ export async function prepareMigrationArgs( await rm(tempDir, { recursive: true, force: true }); 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 selected) { symlinkSync(resolve(dir, file), join(tempDir, file)); } @@ -170,19 +166,14 @@ export async function prepareMigrationArgs( console.log( chalk.blue("migrations"), chalk.gray( - `Prepared ${filesToInclude.length} migration(s) for ${canisterName}` + - (isTrimming ? ` (trimmed from ${allMigrations.length})` : ""), + `Prepared ${selected.length} migration(s) for ${canisterName}` + + (isTrimming ? ` (trimmed from ${all.length})` : ""), ), ); } - const migrationArgs = [`--enhanced-migration=${tempDir}`]; - if (isTrimming) { - migrationArgs.push("-A=M0254"); - } - return { - migrationArgs, + migrationArgs: [`--enhanced-migration=${tempDir}`, ...trimFlag], cleanup: async () => { await rm(tempDir, { recursive: true, force: true }); }, diff --git a/cli/tests/__snapshots__/migrate.test.ts.snap b/cli/tests/__snapshots__/migrate.test.ts.snap index 1e00ca87..864b5802 100644 --- a/cli/tests/__snapshots__/migrate.test.ts.snap +++ b/cli/tests/__snapshots__/migrate.test.ts.snap @@ -85,6 +85,18 @@ check-stable Comparing deployed.most ↔ .mops/.check-stable/new.most } `; +exports[`migrate check check-limit=1 with pending next reports real next-migration path on error 1`] = ` +{ + "exitCode": 1, + "stderr": "next-migration/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050], literal of type + Text +does not have expected type + Nat +✗ Check failed for canister backend (exit code: 1)", + "stdout": "", +} +`; + exports[`migrate check error inside a chain migration reports its file location 1`] = ` { "exitCode": 1, diff --git a/cli/tests/migrate.test.ts b/cli/tests/migrate.test.ts index b6d11a9f..2d2be103 100644 --- a/cli/tests/migrate.test.ts +++ b/cli/tests/migrate.test.ts @@ -168,6 +168,23 @@ describe("migrate", () => { ); await cliSnapshot(["check"], { cwd }, 1); }); + + test("check-limit=1 with pending next reports real next-migration path on error", async () => { + const cwd = await makeTempFixture("with-next"); + await patchMigrations(cwd, "check-limit = 1"); + const nextDir = path.join(cwd, "next-migration"); + const nextFile = readdirSync(nextDir).find((f) => f.endsWith(".mo"))!; + await writeFile( + path.join(nextDir, nextFile), + 'import State "../types/State";\n' + + "module {\n" + + " public func migration(old : State.V3) : State.V4 {\n" + + ' { id = "wrong"; name = old.name; email = old.email };\n' + + " };\n" + + "};\n", + ); + await cliSnapshot(["check"], { cwd }, 1); + }); }); describe("build", () => { diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 48eddfcc..e46d6018 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -169,6 +169,8 @@ When `[migrations]` is configured, do not add `--enhanced-migration` to `[canist When a `next` migration exists or chain trimming is active, mops stages the active chain into `/.migrations-/` for compilation. This keeps the staged files at the same depth as the originals so relative imports (e.g. a shared `types/` folder next to `chain` and `next`) resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. When this staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`) rather than the original `migrations/...` path. The staged directory is removed when the command finishes, so editor "jump to error" on the printed path will not find the file — the actual source is at the corresponding `/.mo` (or `/.mo` for the pending one). + +As a special case, `check-limit = 1` with a pending `next` migration skips staging entirely: moc is pointed at the `next` directory directly, and errors in that migration reference the real path. ::: Shorthand — when only the entrypoint is needed: diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index 24619839..b93def72 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -61,7 +61,9 @@ build-limit = 100 `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders (e.g. a shared `types/` folder) using relative paths — mops stages the active chain into `/.migrations-/` for compilation, preserving the depth of the originals so relative imports resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. -When staging is active (any time `next` is present or `check-limit`/`build-limit` trims the chain), `moc` reports diagnostics for chain files against the staged path — e.g. `.migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050] ...`, not `migrations/20250301_000000_AddEmail.mo`. The staged directory is deleted when the command finishes, so the printed path will not resolve in your editor — open the matching file under `chain` (or `next`, if it is the pending migration) instead. +When staging is active, `moc` reports diagnostics for chain files against the staged path — e.g. `.migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050] ...`, not `migrations/20250301_000000_AddEmail.mo`. The staged directory is deleted when the command finishes, so the printed path will not resolve in your editor — open the matching file under `chain` (or `next`, if it is the pending migration) instead. + +The common dev-loop case bypasses staging: with `check-limit = 1` and a pending `next` migration, mops points moc at `next-migration/` directly, so errors in the migration you are actively editing reference the real path. See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. From 5ca8de9d1b931d60979359c43211ad06f4ce2a4a Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 14:17:10 +0200 Subject: [PATCH 4/8] simplify(migrate): minimal migrations.ts diff + cover empty-chain shortcut Revert the structural refactor of `prepareMigrationArgs` from the previous commit; the only behavior change needed is a single early- return branch for "moc only needs the pending next migration". Net change in migrations.ts is now +10/-0 instead of churning the existing shape. Also align the three doc locations with the changelog (both `check-limit = 1 with pending next` and `no frozen chain yet` trigger the shortcut), and add a snapshot test for the empty-chain branch so that case is pinned, not just structurally implied. Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 +- cli/helpers/migrations.ts | 73 +++++++++++++------- cli/tests/__snapshots__/migrate.test.ts.snap | 12 ++++ cli/tests/migrate.test.ts | 20 +++++- docs/docs/09-mops.toml.md | 2 +- docs/docs/cli/4-dev/08-mops-migrate.md | 2 +- 6 files changed, 79 insertions(+), 32 deletions(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index f7c15519..a7b358ed 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -146,7 +146,7 @@ When `[canisters..migrations]` is configured, `mops check`, `mops build`, `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders via relative paths (e.g. shared types in `src/backend/types/`); mops stages the active chain into `/.migrations-/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`. -When staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged directory is removed at the end of the command, so the printed path will not open in an editor — look up the same filename under `chain` (or `next` for the pending migration). The dev-loop case (`check-limit = 1` with a pending `next`) skips staging and reports the real `next-migration/.mo` path. +When staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged directory is removed at the end of the command, so the printed path will not open in an editor — look up the same filename under `chain` (or `next` for the pending migration). The dev-loop case (`check-limit = 1` with a pending `next`, or no frozen chain yet) skips staging and reports the real `next-migration/.mo` path. Typical workflow: make a breaking change → `mops check` fails with a hint → `mops migrate new Name` → edit migration → `mops check` passes → `mops build` → deploy → `mops migrate freeze`. diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts index 70929f65..cfc5267a 100644 --- a/cli/helpers/migrations.ts +++ b/cli/helpers/migrations.ts @@ -101,8 +101,13 @@ export async function prepareMigrationArgs( mode: "check" | "build", verbose?: boolean, ): Promise { + const noOp: MigrationArgsResult = { + migrationArgs: [], + cleanup: async () => {}, + }; + if (!migrations) { - return { migrationArgs: [], cleanup: async () => {} }; + return noOp; } validateMigrationsConfig(migrations, canisterName); @@ -121,44 +126,55 @@ export async function prepareMigrationArgs( } const chainFiles = getMigrationFiles(chainDir); + if (nextFile) { validateNextMigrationOrder(chainFiles, nextFile); } - // Virtual merged list: chain files (in chainDir) followed by the pending next file (in nextDir) - const all = [ - ...chainFiles.map((file) => ({ file, dir: chainDir })), - ...(nextFile && nextDir ? [{ file: nextFile, dir: nextDir }] : []), - ]; + // Treat chain + next as one virtual merged list + type MigrationEntry = { file: string; dir: string }; + const allMigrations: MigrationEntry[] = chainFiles.map((f) => ({ + file: f, + dir: chainDir, + })); + if (nextFile && nextDir) { + allMigrations.push({ file: nextFile, dir: nextDir }); + } const limit = mode === "check" ? migrations["check-limit"] : migrations["build-limit"]; - const isTrimming = limit !== undefined && limit < all.length; - const selected = isTrimming ? all.slice(-limit) : all; - const trimFlag = isTrimming ? ["-A=M0254"] : []; - - // Skip staging when the selection is exactly the contents of one real - // directory: the whole chain (no trimming, no next), or the single pending - // next migration. moc then reports diagnostics against the real path the - // user is editing instead of a staged copy that gets cleaned up. - const realDir = - !isTrimming && !nextFile - ? chainDir - : selected.length === 1 && selected[0]!.dir === nextDir - ? nextDir - : null; - if (realDir) { + const isTrimming = limit !== undefined && limit < allMigrations.length; + const needsTempDir = nextFile !== null || isTrimming; + + if (!needsTempDir) { return { - migrationArgs: [`--enhanced-migration=${realDir}`, ...trimFlag], + migrationArgs: [`--enhanced-migration=${chainDir}`], cleanup: async () => {}, }; } + // Skip staging when moc only needs the pending next migration: point it at + // next-migration/ directly so diagnostics use the real path the user is + // editing instead of a staged copy that gets cleaned up before the error + // is read. Applies when the chain is empty or trimmed to 1. + if (nextFile && nextDir && (chainFiles.length === 0 || limit === 1)) { + const migrationArgs = [`--enhanced-migration=${nextDir}`]; + if (isTrimming) { + migrationArgs.push("-A=M0254"); + } + return { migrationArgs, cleanup: async () => {} }; + } + const tempDir = stagedMigrationsDir(chainDir, canisterName); await rm(tempDir, { recursive: true, force: true }); mkdirSync(tempDir, { recursive: true }); writeFileSync(join(tempDir, ".gitignore"), "*\n"); - for (const { file, dir } of selected) { + + const filesToInclude = isTrimming + ? allMigrations.slice(-limit) + : allMigrations; + + for (const { file, dir } of filesToInclude) { symlinkSync(resolve(dir, file), join(tempDir, file)); } @@ -166,14 +182,19 @@ export async function prepareMigrationArgs( console.log( chalk.blue("migrations"), chalk.gray( - `Prepared ${selected.length} migration(s) for ${canisterName}` + - (isTrimming ? ` (trimmed from ${all.length})` : ""), + `Prepared ${filesToInclude.length} migration(s) for ${canisterName}` + + (isTrimming ? ` (trimmed from ${allMigrations.length})` : ""), ), ); } + const migrationArgs = [`--enhanced-migration=${tempDir}`]; + if (isTrimming) { + migrationArgs.push("-A=M0254"); + } + return { - migrationArgs: [`--enhanced-migration=${tempDir}`, ...trimFlag], + migrationArgs, cleanup: async () => { await rm(tempDir, { recursive: true, force: true }); }, diff --git a/cli/tests/__snapshots__/migrate.test.ts.snap b/cli/tests/__snapshots__/migrate.test.ts.snap index 864b5802..4a0c614c 100644 --- a/cli/tests/__snapshots__/migrate.test.ts.snap +++ b/cli/tests/__snapshots__/migrate.test.ts.snap @@ -97,6 +97,18 @@ does not have expected type } `; +exports[`migrate check empty chain with pending next reports real next-migration path on error 1`] = ` +{ + "exitCode": 1, + "stderr": "next-migration/20250401_000000_RenameId.mo:4.12-4.19: type error [M0050], literal of type + Text +does not have expected type + Nat +✗ Check failed for canister backend (exit code: 1)", + "stdout": "", +} +`; + exports[`migrate check error inside a chain migration reports its file location 1`] = ` { "exitCode": 1, diff --git a/cli/tests/migrate.test.ts b/cli/tests/migrate.test.ts index 2d2be103..48e2a989 100644 --- a/cli/tests/migrate.test.ts +++ b/cli/tests/migrate.test.ts @@ -169,9 +169,7 @@ describe("migrate", () => { await cliSnapshot(["check"], { cwd }, 1); }); - test("check-limit=1 with pending next reports real next-migration path on error", async () => { - const cwd = await makeTempFixture("with-next"); - await patchMigrations(cwd, "check-limit = 1"); + async function corruptNextMigration(cwd: string): Promise { const nextDir = path.join(cwd, "next-migration"); const nextFile = readdirSync(nextDir).find((f) => f.endsWith(".mo"))!; await writeFile( @@ -183,6 +181,22 @@ describe("migrate", () => { " };\n" + "};\n", ); + } + + test("check-limit=1 with pending next reports real next-migration path on error", async () => { + const cwd = await makeTempFixture("with-next"); + await patchMigrations(cwd, "check-limit = 1"); + await corruptNextMigration(cwd); + await cliSnapshot(["check"], { cwd }, 1); + }); + + test("empty chain with pending next reports real next-migration path on error", async () => { + const cwd = await makeTempFixture("with-next"); + const chainDir = path.join(cwd, "migrations"); + for (const f of readdirSync(chainDir).filter((f) => f.endsWith(".mo"))) { + await rm(path.join(chainDir, f)); + } + await corruptNextMigration(cwd); await cliSnapshot(["check"], { cwd }, 1); }); }); diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index e46d6018..042b26b9 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -170,7 +170,7 @@ When a `next` migration exists or chain trimming is active, mops stages the acti When this staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`) rather than the original `migrations/...` path. The staged directory is removed when the command finishes, so editor "jump to error" on the printed path will not find the file — the actual source is at the corresponding `/.mo` (or `/.mo` for the pending one). -As a special case, `check-limit = 1` with a pending `next` migration skips staging entirely: moc is pointed at the `next` directory directly, and errors in that migration reference the real path. +As a special case, mops skips staging entirely when moc only needs to see the pending `next` migration — typically `check-limit = 1` with a pending `next`, or no frozen chain yet. moc is pointed at the `next` directory directly, and errors in that migration reference the real path. ::: Shorthand — when only the entrypoint is needed: diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index b93def72..af5d873b 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -63,7 +63,7 @@ build-limit = 100 When staging is active, `moc` reports diagnostics for chain files against the staged path — e.g. `.migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050] ...`, not `migrations/20250301_000000_AddEmail.mo`. The staged directory is deleted when the command finishes, so the printed path will not resolve in your editor — open the matching file under `chain` (or `next`, if it is the pending migration) instead. -The common dev-loop case bypasses staging: with `check-limit = 1` and a pending `next` migration, mops points moc at `next-migration/` directly, so errors in the migration you are actively editing reference the real path. +The common dev-loop case bypasses staging: when moc only needs to see the pending `next` migration — typically `check-limit = 1` with a pending `next`, or no frozen chain yet — mops points moc at `next-migration/` directly, so errors in the migration you are actively editing reference the real path. See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. From 9df36816d8d8b2bb23ffd02c09cd7c57b4157837 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 14:22:16 +0200 Subject: [PATCH 5/8] docs(skill): trim staged-path note to the two facts that matter Made-with: Cursor --- .agents/skills/mops-cli/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/mops-cli/SKILL.md b/.agents/skills/mops-cli/SKILL.md index a7b358ed..b90659ac 100644 --- a/.agents/skills/mops-cli/SKILL.md +++ b/.agents/skills/mops-cli/SKILL.md @@ -146,7 +146,7 @@ When `[canisters..migrations]` is configured, `mops check`, `mops build`, `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders via relative paths (e.g. shared types in `src/backend/types/`); mops stages the active chain into `/.migrations-/`, preserving depth so relative imports resolve identically. The staged dir self-stamps a `.gitignore`; `mops init` also adds `.migrations-*/` to the project `.gitignore`. -When staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`), not the original `migrations/` path. The staged directory is removed at the end of the command, so the printed path will not open in an editor — look up the same filename under `chain` (or `next` for the pending migration). The dev-loop case (`check-limit = 1` with a pending `next`, or no frozen chain yet) skips staging and reports the real `next-migration/.mo` path. +`moc` diagnostics may print paths under `.migrations-/` — a staging dir that mops removes when the command finishes. The real file has the same name under `chain/` or `next/`. Typical workflow: make a breaking change → `mops check` fails with a hint → `mops migrate new Name` → edit migration → `mops check` passes → `mops build` → deploy → `mops migrate freeze`. From 0804bcf508e4f40fc31aa92ef8d60fceba30647d Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 14:22:58 +0200 Subject: [PATCH 6/8] docs(migrate): shorten staging-shortcut comment Made-with: Cursor --- cli/helpers/migrations.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cli/helpers/migrations.ts b/cli/helpers/migrations.ts index cfc5267a..a572a2f7 100644 --- a/cli/helpers/migrations.ts +++ b/cli/helpers/migrations.ts @@ -153,10 +153,8 @@ export async function prepareMigrationArgs( }; } - // Skip staging when moc only needs the pending next migration: point it at - // next-migration/ directly so diagnostics use the real path the user is - // editing instead of a staged copy that gets cleaned up before the error - // is read. Applies when the chain is empty or trimmed to 1. + // 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)) { const migrationArgs = [`--enhanced-migration=${nextDir}`]; if (isTrimming) { From be48f322b6285a9d578abb216f484510b38c7374 Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 14:32:12 +0200 Subject: [PATCH 7/8] docs: trim staged-path note in user docs to one sentence Made-with: Cursor --- docs/docs/09-mops.toml.md | 4 +--- docs/docs/cli/4-dev/08-mops-migrate.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/docs/09-mops.toml.md b/docs/docs/09-mops.toml.md index 042b26b9..530a6e66 100644 --- a/docs/docs/09-mops.toml.md +++ b/docs/docs/09-mops.toml.md @@ -168,9 +168,7 @@ When `[migrations]` is configured, do not add `--enhanced-migration` to `[canist :::note When a `next` migration exists or chain trimming is active, mops stages the active chain into `/.migrations-/` for compilation. This keeps the staged files at the same depth as the originals so relative imports (e.g. a shared `types/` folder next to `chain` and `next`) resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. -When this staging is active, `moc` diagnostics for chain files reference the staged path (e.g. `.migrations-backend/20250301_000000_AddEmail.mo`) rather than the original `migrations/...` path. The staged directory is removed when the command finishes, so editor "jump to error" on the printed path will not find the file — the actual source is at the corresponding `/.mo` (or `/.mo` for the pending one). - -As a special case, mops skips staging entirely when moc only needs to see the pending `next` migration — typically `check-limit = 1` with a pending `next`, or no frozen chain yet. moc is pointed at the `next` directory directly, and errors in that migration reference the real path. +`moc` diagnostics may point to a staged path under `.migrations-/`, which mops removes when the command finishes. ::: Shorthand — when only the entrypoint is needed: diff --git a/docs/docs/cli/4-dev/08-mops-migrate.md b/docs/docs/cli/4-dev/08-mops-migrate.md index af5d873b..4a9e6b8c 100644 --- a/docs/docs/cli/4-dev/08-mops-migrate.md +++ b/docs/docs/cli/4-dev/08-mops-migrate.md @@ -61,9 +61,7 @@ build-limit = 100 `chain` and `next` must live in the same parent directory. Migration files can import from sibling folders (e.g. a shared `types/` folder) using relative paths — mops stages the active chain into `/.migrations-/` for compilation, preserving the depth of the originals so relative imports resolve identically. The staged dir self-stamps a `.gitignore`, and `mops init` adds `.migrations-*/` to the project `.gitignore`. -When staging is active, `moc` reports diagnostics for chain files against the staged path — e.g. `.migrations-backend/20250301_000000_AddEmail.mo:4.24-4.26: type error [M0050] ...`, not `migrations/20250301_000000_AddEmail.mo`. The staged directory is deleted when the command finishes, so the printed path will not resolve in your editor — open the matching file under `chain` (or `next`, if it is the pending migration) instead. - -The common dev-loop case bypasses staging: when moc only needs to see the pending `next` migration — typically `check-limit = 1` with a pending `next`, or no frozen chain yet — mops points moc at `next-migration/` directly, so errors in the migration you are actively editing reference the real path. +`moc` diagnostics may point to a staged path under `.migrations-/`, which mops removes when the command finishes. See [`mops.toml` reference](/mops.toml#canistersnamemigrations) for all fields. From 40bfa142f1bb8d5ad099f2dca19f3b10278be4bf Mon Sep 17 00:00:00 2001 From: Kamil Listopad Date: Tue, 21 Apr 2026 14:33:01 +0200 Subject: [PATCH 8/8] docs(changelog): simplify staging-shortcut entry Made-with: Cursor --- cli/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 153fde82..19b8032c 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,7 +1,7 @@ # Mops CLI Changelog ## Next -- `mops check`/`build`/`check-stable` skip migration staging when the active window is exactly the pending `next` migration (typically `check-limit = 1` with a pending next, or no frozen chain yet). moc is pointed at `next-migration/` directly, so diagnostics reference the real file path you are editing instead of a staged copy that gets cleaned up before you read the error. +- `mops check`/`build`/`check-stable` skip migration staging when only the pending `next` migration is needed, so `moc` diagnostics reference the real `next-migration/` path. ## 2.12.0 - Migration staging directory moved from `.mops/.migrations//` to `/.migrations-/`, so migration files can import shared modules from sibling folders (e.g. a `types/` folder next to `migrations/`) — relative imports now resolve to the same target whether moc reads the original chain dir or the staged one. The staged dir self-stamps a `.gitignore` so it doesn't pollute `git status`; `mops init` now also adds `.migrations-*/` to the project `.gitignore`