Skip to content

Support enhanced migration from mops.toml#451

Draft
Kamirus wants to merge 17 commits intomasterfrom
feat/enhanced-migration-test
Draft

Support enhanced migration from mops.toml#451
Kamirus wants to merge 17 commits intomasterfrom
feat/enhanced-migration-test

Conversation

@Kamirus
Copy link
Copy Markdown
Contributor

@Kamirus Kamirus commented Apr 10, 2026

Problem

The VS Code extension didn't support Motoko's enhanced migration feature. Projects using [canisters.<name>.migrations] in mops.toml (mops 2.11.0+) saw spurious M0257 errors on uninitialized stable variables, because the extension never passed --enhanced-migration to the type checker.

The naive fix — set --enhanced-migration globally via setExtraFlags — corrupts every other file in the workspace: stable variables without initializers become legal everywhere, hiding real errors in non-canister code (e.g. an IntegrationTest.mo actor that should still require initializers).

Root cause — why "just swap flags before each check" doesn't work

--enhanced-migration is per-canister: it should apply when checking the canister's main actor, but not when checking other files. Two sub-problems made the obvious approaches unworkable:

  1. moc.js (Flags) state is global within an instance. setExtraFlags mutates OCaml refs; there is no API to swap flags atomically per check, and LSP request handling can interleave.
  2. require() returns singletons — so does jest's module registry. A fresh require('motoko/.../moc.min.js') returns the same compiler object every time, even after delete require.cache[...]. You can't "just" spin up a second moc.js instance the obvious way.

Fix

For each canister with a [canisters.<name>.migrations] chain, register a dedicated Context keyed by the canister's main file URI. Each such context owns its own moc.js instance loaded via Module._compile, which bypasses both Node's require.cache and jest's module registry, giving genuinely isolated Flags state per canister.

Routing in getContext/hasContext is now path-segment aware (matchesContext) so canister-file URIs don't accidentally match siblings whose names extend them as a prefix (e.g. main.mo vs. main.module.mo).

Build pipeline: npm run prepare:moc-bundled copies node_modules/motoko/versions/latest/moc.min.js to src/server/compiler/moc-bundled.js so loadFreshCompiler has a stable on-disk path in dev, jest, and the bundled extension.

Bumps the motoko dependency to ^4.4.0 (--enhanced-migration was added in 4.3.0).

Caveats

  • Only the frozen chain directory is supported. next-migration and check-limit are ignored — users staging the next migration in next-migration won't get type-checked feedback for it from the canister's context.
  • Each per-canister moc.js instance adds a few MB of memory; cleared on resetContexts.
  • Module._compile is a private Node API. It works in both Node and jest today; if it breaks in a future Node version we can fall back to vm.Script.

Test plan

  • enhancedMigration.spec.ts: distinct compiler instances per canister, behavioral probe that flags set on one context don't leak to the other, end-to-end compile of a canister with a 3-stage migration chain (stable variable type evolution + shared types/State.mo import), and a sibling test canister that compiles correctly without the flag
  • Full suite: 280/280 pass locally
  • CI: green on prior commits; latest commit re-triggers

Kamirus added 4 commits April 10, 2026 15:00
Test that the language server correctly handles `--enhanced-migration`
from mops.toml, verifying that relative migration paths resolve against
the source file's directory in moc.js.

Requires caffeinelabs/motoko#6002 to be released in a new motoko npm
package.

Made-with: Cursor
…ons]

Parse the mops 2.11.0 per-canister migrations config (chain/next dirs)
and apply --enhanced-migration as a per-file flag before type-checking
the canister's main actor, restoring default flags afterwards.

The moc.js compiler (js_of_ocaml) stores Flags state in globalThis,
so separate instances cannot be created via require.cache clearing.
Instead, flags are swapped around each check call.

When next-migration has .mo files, it is preferred over the frozen
chain dir (moc accepts a suffix of the migration chain).

Made-with: Cursor
@Kamirus Kamirus changed the title Add enhanced migration test Support enhanced migration from mops.toml Apr 20, 2026
Kamirus added 13 commits April 20, 2026 11:09
The --enhanced-migration flag was added in motoko@4.3.0. The previous
lockfile pinned 4.2.0 which doesn't support this flag, causing CI to
fail with M0001 syntax errors on uninitialized let declarations.

Made-with: Cursor
Adds types/State.mo with a Counter type used by both the last
migration and main.mo, verifying cross-module imports resolve
correctly during type-checking.

Made-with: Cursor
moc.js's setExtraFlags does not reset the --enhanced-migration flag
between calls, causing the flag to leak across canisters that share
a single moc.js instance. Each canister with a [canisters.<name>.migrations]
section now gets its own moc.js compiler instance with the flag baked
into its mopsArgs.

Compiler isolation is achieved by re-executing moc.min.js via Module._compile
to obtain an independent module.exports (and therefore independent Flags
state). The bundled moc.min.js is shipped separately as
src/server/compiler/moc-bundled.js (gitignored, copied via prepare:moc-bundled
from postinstall, pretest, and compile:motoko[:dev]) so a single code path
works in both jest and the bundled extension.

Per-canister setup failures no longer abort workspace setup.

Made-with: Cursor
The bundled fallback in `addIsolatedContext` (`?? bundledMocJsPath`) only
existed because the bundled `MocJsInfo` left `path` undefined. Setting
`path: bundledMocJsPath` directly when constructing the bundled `MocJsInfo`
makes the field required and removes the conditional.

Existing mocJsPath tests that asserted `path === undefined` for the bundled
fallback now check `source === 'bundled'`, which is the actual invariant
they meant to verify.

Made-with: Cursor
Drop the parallel addIsolatedContext + manual wiring in handlers in favor
of an `isolated` flag threaded through addContext → requestMotokoInstance
→ createMotokoInstance. The same moc.js resolution (custom path → workspace
toolchain version → bundled fallback) now serves both shared workspace
contexts and per-canister isolated ones; only the loader differs (require
vs Module._compile via loadFreshCompiler).

Cache key includes the isolated flag so an isolated and a shared instance
for the same URI don't collide.

Replace `require('motoko/lib').default(compiler)` calls with the already-
imported `wrapMotoko` for consistency. Shorten loadFreshCompiler docstring.

Made-with: Cursor
Each unique cache key already gets a fresh moc.js instance via
loadFreshCompiler; the canister `uri` is the disambiguator. The flag
was dead weight.

Also drop the require.cache cleanup (no longer using require for moc.js)
and the unused `motokoPath` indirection. The default context keeps the
npm `motoko` singleton so tests that import it directly stay in sync.

Made-with: Cursor
Restore wrapMotoko/defaultMotoko/path-required refactors that were not
needed for the canister isolation feature. Keep only:

- loadFreshCompiler + Module._compile to spawn isolated moc.js instances
- bundled fallback uses loadFreshCompiler so the canister context cache
  produces independent Flags state per canister
- prepare:moc-bundled script wired into postinstall, pretest, compile

Made-with: Cursor
Made-with: Cursor
The require.cache cleanup in requestMotokoInstance is dead now that all
compiler loads go through loadFreshCompiler (which uses Module._compile
and never touches the require cache). Also drop the existsSync /
missing-export checks: getCompiler() already wraps in try/catch and
returns Result, so readFileSync's ENOENT and a downstream wrap failure
surface the same way.

Made-with: Cursor
- handlers: skip + warn when canister mainUri collides with workspace context
- handlers: extract applyPackages helper, demote per-canister log to debug
- context: matchesContext gates getContext/hasContext on path-segment boundary
  (prevents main.mo accidentally matching main.module.mo)
- context: clear motokoInstances in resetContexts to bound memory
- utils: distinguish missing mops.toml (silent) from parse error (warn)
- test: add behavioral probe verifying --enhanced-migration set on backend
  ctx does not leak into the workspace ctx; restore via applyMocFlags
- fixture: drop misleading [toolchain] override (suite uses bundled moc.js)

Made-with: Cursor
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant