feat(cli)!: adopt the cargo lock model — --locked, self-healing install, download-time verification - #681
Merged
Merged
Conversation
Contributor
Cursor AI review👀 HUMAN REVIEW REQUESTED — significant intended changes detected
Significant Changes Requiring Human Review
VerdictDecision: REQUEST_HUMAN_REVIEW Generated for commit a8526d3 |
…ll, download-time verification
Replaces `--lock <check|update|ignore>` with a single `--locked` flag and two
modes: plain commands are the dev flow, `--locked` is the CI flow.
- `--lock check` -> `--locked`, which additionally never writes the lockfile.
Available on `mops install` and on every implicitly-installing command, so CI
can run `mops test --locked` with no prior install. `mops sources` has none by
design: the dfx packtool invokes it mid-build and parses its stdout.
- `--lock update` -> plain `mops install`, now self-healing. A missing,
unparseable, legacy-format or mops.toml-inconsistent lock is regenerated, as
are locks carrying absolute local `path` entries from a pre-2.19.2 CLI.
- `--lock ignore` -> no successor; the lock is always maintained. The internal
skip that `mops sources` needs stays, as a non-user-facing LockPolicy value.
- The `CI` env var no longer switches install to check mode (deprecated in
2.18). `cli/helpers/deprecate-ci-lock.ts` is deleted.
Integrity now runs at download time: files are hashed as they arrive and
compared against the registry, and the package reaches the cache only if every
file matches. `mops install` no longer re-hashes `.mops/` on every run, so
editing a dependency in place is tolerated — `mops verify` is the on-demand
on-disk audit and the replacement for anyone using install as a tamper gate.
`--locked` and `mops verify` deliberately do not re-walk the dependency graph.
A lock-driven install skips the versions that lost a version conflict, so their
manifests are never cached and `resolvePackages({skipLock: true})` throws ENOENT
on a fresh clone. They check instead that the lock is present, parseable,
current-format, pins every dependency declared in mops.toml, and agrees with the
registry on every file hash.
Also fixes `mops install` running the conflicts check after a failed install,
which crashed on manifests the failed install never wrote.
Refs #516, #517
…ing guidance - New `mops verify` page; `--locked` documented on install and on every implicitly-installing command; `--lock` sections removed from add/remove/ update/sync. - mops.lock page rewritten: self-healing rules, what `--locked` does and does not check, download-time integrity, and the CI recipe. - Flips the lockfile commit guidance — everyone commits mops.lock, libraries included. A library's lock cannot pin anything for its consumers, and it makes the library's own CI reproducible. The `mops.lock created.` message says so too, instead of telling library authors to gitignore it. - `mops sources` documents why it has no `--locked`. - CHANGELOG entries with migration notes, including the guarantee change that install no longer verifies files already on disk, and the measured cost of the re-hash that was removed. - NEXT-MAJOR.md items marked done, with the `--locked` design correction and the recorded interaction with the resolver work (#679). Refs #516, #517
The verify path no longer re-resolves the dependency graph, so the two comments describing it in terms of resolution were wrong.
…e hints Addresses review findings on the lock-model change. - A lock that is valid JSON but structurally wrong (missing or non-object `deps`/`hashes`, non-string or empty values) crashed with an unhandled TypeError in `mops install`, `--locked`, `mops verify` and `mops sources` instead of self-healing. Shape is now validated at the read boundary, so a malformed lock is reported as unparseable and regenerated. - `checkLockFileLight` and the `--locked` gate had drifted: `--locked` accepted a lock carrying absolute local paths that `checkLockFileLight` rejects, so `installAll` fell through to re-resolving mops.toml — a resolution change in the mode meant to forbid one. Both now derive from a single `inspectLockFile`, which makes the divergence unrepresentable, plus an assertion in `installAll`. - Folding the manifest comparison into that inspection closes a real hole: a `deps` entry hand-edited to a different version than mops.toml declares used to be installed as-is, silently giving the wrong version. Plain `mops install` now re-resolves. A `hashes` section disagreeing with `deps` self-heals too; both checks are offline and free. - A download failing its integrity check no longer lets `build`, `check`, `check-candid`, `check-stable`, `test`, `bench` and `generate candid` continue against a partially-populated `.mops/`. They exit 1 like `install` does. - `--locked` and `mops verify` told users to run `mops install` when the lock's recorded file *hash values* disagreed with the registry, which install does not repair — an infinite loop for CI. Those paths now give the hint that works. Repairing it automatically would mean a `getFileHashesByPackageIds` update call (~1.2s measured) on every install to catch a hand-edit, roughly nine times the cost this release saves, for values the build never reads. Documented instead. - A package the registry publishes no hashes for is still installed, but now says so rather than passing off unverified bytes as verified. - Restored the `mops check-candid --help` description, dropped when `--locked` was wired in. Refs #516, #517
The cherry-pick left conflict markers in the changelog, and #677's lock entry survived alongside the one that replaces it — it promised a tamper-failure and `--lock update` recovery, both of which this branch removes.
Kamirus
force-pushed
the
kamil-v3/lock-model
branch
from
August 6, 2026 12:07
c93f6d7 to
a8526d3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CI pipelines get a real frozen-lockfile flag, and everyone else stops paying for a check that was re-reading the whole dependency tree on every command.
mopshad three lockfile modes behind--lock <check|update|ignore>, plus a fourth that appeared only when theCIenvironment variable happened to be set. This replaces all of it with the model cargo, pnpm and yarn converged on: two modes, one flag. Plain commands are the dev flow and always keep the lock correct.--lockedis the CI flow — it requires an up-to-date lock and never writes one.Integrity moves with it. Verification now happens on the bytes as they are downloaded, so a corrupted or tampered download can never enter the cache, and installs no longer re-hash
.mops/. The on-disk audit becomes an explicit command,mops verify.Closes #516. Closes #517.
Before / After
A missing lock in CI. Before,
mops testwould silently resolve, download and write a lockfile nobody reviewed. After:It fails before downloading anything.
Someone edited
mops.tomland forgot to commit the lock.Same repo, without
--locked— the lock is simply brought up to date:A hand-edited lock that no longer matches the manifest.
A lock whose recorded hashes disagree with the registry. Note the hint differs here: plain
mops installdoes not rewrite this lock, so pointing at it would loop (see below).A corrupt lock — the self-healing path. This used to require
mops install --lock update, and #514 was about that flag not actually working:No flag recovers a broken lock any more, because none is needed.
An up-to-date lock under
--lockedis left alone — byte-identical content and unchanged mtime, verified incli/tests/locked.test.ts.The guarantee change: install no longer verifies files already on disk
Neither
mops installnormops install --lockedgates a modified.mops/tree any more.mops verifyis the replacement. Read this if any pipeline relies on the old behavior.This is intentional and is the reason the item sits in a major. It is called out here, in
cli/CHANGELOG.md, on themops.lockdocs page and on the newmops verifypage, and it is pinned by a test in bothcli/tests/locked.test.tsandcli/tests/build.test.ts.Integrity is now checked once, at download time, against the hashes published in the registry, before the package is committed to the cache. The per-install re-hash of
.mops/is gone. Editing a dependency in place therefore no longer fails your next command:mops verifyis the replacement, and it reports exactly what install used to:Two consequences worth stating plainly:
mops verifyaudits them;mops cache cleanforces a verified re-download..mops/:One case is deliberately not self-healed
A lockfile whose recorded file hash values are wrong — reachable only by hand-editing or a botched merge — is not repaired by
mops install, because detecting it needsgetFileHashesByPackageIds, which is an update call, not a query:mops installPaying ~1.2 s on every install to catch a hand-edit would cost roughly nine times the 136 ms this PR saves, and a warm cache fetches no hashes at download time either, so there is nothing already in hand to compare against. Since those values are read only by
--lockedandmops verifyand never by the build, a wrong hash cannot produce a wrong build.What I did instead:
--lockedandmops verifyreport it with the hint that actually recovers (restore from version control, or delete and reinstall) rather than "runmops install", which would have looped forever. Covered by a test that asserts the working hint and walks the recovery.Two related inconsistencies that are detectable offline now self-heal, one of which was a real correctness hole: a
depsentry disagreeing withmops.tomlused to be installed as-is, silently giving you the wrong version. Plainmops installnow re-resolves instead.Migration
mops install --lock checkmops install --lockedmops install --lock updatemops installmops install --lock ignoremops add|remove|update|sync --lock updatemops add|remove|update|syncmops add|remove|update|sync --lock ignoreCI=1 mops install(implicitly meant check)mops install --lockedmops testin CI with no prior installmops test --locked.mops/mops verifymops.lockin a library's.gitignoremops.lockA typical CI job becomes:
--lockedis onmops installand on every implicitly-installing command:build,check,check-candid,check-stable,test,bench,generate candid.mops sourcesdeliberately has none — the dfx packtool invokes it in the middle of a build and machine-parses its stdout, so failing there is a poor place to report a stale lock. Putmops install --lockedearlier in the pipeline. Its stdout and the lock's mtime are unchanged by this PR.Dependency-mutating commands (
add,remove,update,sync) have no--locked: their job is to change dependencies, so they always update the lock.Lockfile commit guidance is now "everyone commits"
mops.lock created.used to printLibraries: add mops.lock to .gitignore.It now printsCommit this file., and the docs say the same. A library's lockfile cannot pin anything for its consumers — they resolve their own graph and write their own lock — while committing it makes the library's own CI reproducible. If you havemops.lockin.gitignorebecause of the old advice, remove it.What
--lockedchecks, and one thing it does not--lockedrequires that the lock is present, parseable, the current format version, pins every dependency declared inmops.tomlto the same value, hasdepsandhashesagreeing on the registry package set, and records a hash for every file that matches the registry. It never writesmops.lock.It does not re-walk the dependency graph to byte-compare a freshly computed lock, which is what I originally implemented. That design does not work, and the reason is worth recording:
Installing from a lockfile passes
ignoreTransitive: true, which is the whole point — it skips downloading the dependency versions that lost a version conflict. Those versions'mops.tomlfiles are therefore never in the cache, soresolvePackages({skipLock: true})throws on them. On a simulated fresh clone (committed lock, cold cache, a diamond wherebase@0.10.2loses tobase@0.14.9) the re-walk crashed:The only ways to make the re-walk sound are to install the losing versions too (giving up the lockfile's main performance benefit, in exactly the CI case
--lockedtargets) or to fetch each losing candidate's manifest individually (a new feature, and a network call per candidate). Neither belongs here. The walk-free checks above cover the realistic drift instead, and published registry versions are immutable, so a transitive version cannot change underneath a lock. The residual gap is transitive changes reached through a localpathdependency, which are live directories by design.This is the same structural limitation the resolver-correctness work hit (#679): a valid lock short-circuits resolution, so a fresh clone never re-walks the graph and never sees the cross-major conflict report. It cannot be closed from the lockfile side either, for the reason above. Recording it here rather than leaving it to be rediscovered.
Performance
The removed re-hash was proportional to the whole dependency tree and paid on every install, build, check and test. Measured on an 842-file / 33.5 MB tree (20 packages):
mops installIsolated, the re-hash itself was ~136 ms (5 runs, 134–149 ms) on a warm page cache and a local SSD. So this is an honest but modest constant-factor win at this tree size — Node startup dominates a warm install — and it scales linearly with tree size and degrades on cold page cache, container overlay filesystems and networked volumes. The primary motivation is the guarantee model; the speed-up is a secondary benefit, and I would not sell it as the headline.
Also in here
mops installnow migrates a lock that still carries absolute localpathentries written by a pre-2.19.2 CLI. That previously required an explicitmops install --lock update, which no longer exists, socheckLockFileLight()treats such a lock as stale.mops installrunning the dependency-conflict check after a failed install, which crashed with an unhandled ENOENT on manifests the failed install never wrote. Pre-existing, but the new download-verification failure path makes it easy to hit.cli/helpers/deprecate-ci-lock.ts(nothing else used it) and the deadcheckRemote()export.deps/hashes) is treated as corrupt and self-healed, rather than crashing with an unhandledTypeError. Shape is now validated at the read boundary.checkLockFileLight(which decides whether to install from the lock) and the--lockedgate are derived from oneinspectLockFilefunction, so they cannot disagree. When they did,--lockedaccepted a lock that install then declined to install from, silently falling back to re-resolvingmops.toml.mops check-candid--helpdescription, dropped by accident when--lockedwas wired in.Verification
Baseline on
kamil-v3/hidden-state: 25 suites / 201 tests / 73 snapshots. Now 26 / 229 / 73, all green (the count includes tests arriving with the merged base).npm run checkandnpm run lintclean.New tests in
cli/tests/locked.test.ts(targeted assertions, no snapshots, per AGENTS.md) cover: missing lock, missing lock on each ofbuild/check/check-stable/test/bench, up-to-date lock left byte- and mtime-identical, changed manifest, unparseable lock, legacy format, locked version disagreeing withmops.toml, locked hash disagreeing with the registry, and the install-tolerates / verify-rejects pair.mops verifygets happy path, missing lock and not-installed cases. They use their own fixtures because Jest runs test files in parallel and sharinginstall/successraces onmops.lock.Two existing tests changed intent deliberately:
build.test.ts"fails on a locally modified.mops/file" became "tolerates ... that mops verify rejects" — that is the guarantee change.local-path-lock.test.ts"--lock updaterewrites absolute local paths" became "plain install rewrites ...".Beyond the suite, I ran a 25-case matrix by hand against the live registry covering the same ground plus
CI=1no longer implying check,--lockbeing rejected on all five commands,--lockedbeing accepted on all seven implicit commands,mops sourceshaving none, and the fresh-clone cold-cache path that caught the design flaw above.Stacked on #677, and synced onto its reworked head. Two conflicts, both resolved in favor of the newer decisions:
cli/cli.ts— feat(cli)!: stricter flags and safer defaults for v3 #676 made unknown flags before--strict by removingallowUnknownOption(true). Kept that;--lockedis a declared option so strict parsing accepts it, and the-- <tool flags>passthrough still works (verified forcheck --nope,build --locked,test -- -Werror).cli/CHANGELOG.md— kept all twelve entries from feat(cli)!: eliminate hidden shared state (network commands, lock bypass, shell init, exit code) #677/feat(cli)!: stricter flags and safer defaults for v3 #676. Dropped this branch's network-relocation entry, superseded by feat(cli)!: eliminate hidden shared state (network commands, lock bypass, shell init, exit code) #677 deletingset-network/get-networkoutright. Kept this branch's version of the entry for the seven implicitly-resolving commands; feat(cli)!: eliminate hidden shared state (network commands, lock bypass, shell init, exit code) #677's still referenced the removed--lock updateflag and the.mops/tamper guarantee that download-time verification replaces.cli/integrity.tsauto-merged to this branch's version unchanged — thedefaultLock/CI-lock machinery it replaced left no residue.Confirmed against the reworked
cleanCache()(now plain whole-tree removal of both caches, with thePROJECT_STATE_FILESspecial case reverted): nothing in the lock or verify work depends on it.mops.locklives at the project root, never inside.mops/, so a fullmops cache cleanleaves it byte-identical, after whichinstall --lockedre-downloads and re-verifies without writing the lock, andmops verifypasses..mops/is pure derived state.Retarget with
gh pr edit 681 --base v3once #677 merges.