fix(cli): survive incomplete global-cache state instead of crashing with ENOENT - #712
Merged
Merged
Conversation
…ith ENOENT A lock-driven install deliberately caches only the versions that won a dependency conflict, but regenerating a stale lock (after add/update/ sync/remove) still walked every declared version's manifest and crashed with a raw ENOENT on the losers. Manifests missing from the cache are now downloaded on demand. Also: a cache entry now counts as cached only if it is complete (empty dirs from interrupted runs are deleted and re-downloaded), syncing .mops restores packages missing from the global cache, and toolchain requirements checks skip packages whose manifest is missing instead of crashing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
Cursor AI review👍 APPROVE — looks safe to merge
VerdictDecision: APPROVE Generated for commit cddcf1a |
automation-sa-sre
approved these changes
Aug 11, 2026
automation-sa-sre
left a comment
There was a problem hiding this comment.
Automated approval: the AI review verdict for cddcf1a is APPROVE. See the "Cursor AI review" comment for details.
This was referenced Aug 11, 2026
Kamirus
added a commit
that referenced
this pull request
Aug 12, 2026
Follow-up to [#712](#712), stacked on its branch. Regenerating a stale `mops.lock` — what `mops add`, `remove`, `update` and `sync` do — re-walks the dependency graph, and the walk needs the declared dependencies of **every** version in it, including versions that lost a conflict. A lock-driven install deliberately never downloads those losers, so before #712 the walk crashed on them, and since #712 it re-downloads them mid-resolution: correct, but it costs network calls for packages that will never be built against, and it cannot run offline. The lock now stores the reasoning, not just the answers: a `graph` section records each registry package version's declared dependencies, losers included. The walk reads a package's deps in tier order — lock graph, then cached manifest, then registry download — so lock regeneration becomes a local computation. This is the same design as `pnpm-lock.yaml`, which records the full graph rather than only resolved winners. ```json { "version": 3, "deps": { "core": "1.0.0", "lib": "./lib" }, "graph": { "core@1.0.0": {}, "core@2.6.1": {} }, "hashes": { "…": "…" } } ``` Why the recorded edges are trustworthy: published registry versions are immutable, so an edge recorded once is true forever. Mutable local path dependencies are the exception — packages declaring one are never recorded and their manifests are always read live from disk (which is free, since they are local). Observable change, on a fresh machine with a committed lock: Before (with #712): `mops add base@0.16.0` downloads the losing `core@2.6.1` into the global cache just to read its manifest. After: the add completes without touching `core@2.6.1` at all — the regression test asserts the package is absent from the cache after the add, and that the rewritten lock's `graph` gained the new package's edges. ## Compatibility `graph` is an optional field on the existing v3 lock, not a version bump. Older CLIs ignore it; if an older CLI rewrites the lock the field disappears, and this CLI treats an absent or partial `graph` as "fall through to cache, then fetch-on-miss" — i.e. exactly the #712 behavior, which a dedicated test now pins by stripping `graph` from a lock and re-running the flow. `mops install --lock update` upgrades a pre-graph lock in place. The graph reader also tolerates a corrupt lock (returns empty instead of erroring), so `--lock update` as a recovery command keeps working. ## What is unchanged Winner selection, conflict warnings, `deps`, `hashes`, and `--locked` semantics are untouched — the graph only changes *where* the walk reads dependency lists from, not what it computes. GitHub dependencies are not recorded (only commit-pinned ones would be safely immutable) and keep the cache-or-fetch path; worth revisiting if they show up in the same failure class. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Kamirus
added a commit
that referenced
this pull request
Aug 12, 2026
#717) Ports the main-line lock stack — [#712](#712), [#713](#713), [#714](#714) — into v3's restructured code. The invariants transfer; the code expressing them is re-fitted to v3's lock policy model (`maintain`/`locked`/`skip`) rather than merged textually, since v3 rewrote `integrity.ts` and `resolve-packages.ts`. The crash class this closes exists identically on v3: `computeLockFile`'s own comment documents that a stale-lock re-walk "would throw ENOENT" on conflict losers a lock-driven install never downloads — and on v3 the exposure is wider than on 2.x, because the `maintain` flow regenerates the lock on every stale state with no `--lock ignore` opt-out. What lands, adapted to v3: - **Lock graph**: `mops.lock` gains the optional `graph` section (declared deps of every registry version, losers included). The walk reads dependency lists lock-graph → cache → registry, so regeneration is a local computation. v3's `hasValidShape` ignores unknown fields, so pre-graph locks and older CLIs interoperate; a malformed `graph` is ignored (it is an optimization, never a gate) and `inspectLockFile` deliberately does not validate it, so `--locked` keeps passing on pre-graph locks. - **Hash carry-over**: `computeLockFile` reuses hashes of already-locked packages (published versions are immutable) and queries the registry only for packages new to the lock; `mops remove` regenerates with zero registry calls. There is no force flag to port: every self-heal case (missing/unparseable/legacy lock) yields nothing to carry, so v3's documented recovery — `RESTORE_HINT`'s "delete it and run `mops install`" — remains a guaranteed full refetch structurally. A tampered-but-parseable lock is carried, not silently repaired; `mops verify` reports it and the test pins that flow. - **Atomic lock write**: v3 had the same non-atomic `writeFileSync` that made parallel `mops install` crash on a torn lock in main's CI. - **Cache resilience**: `isDepCached` requires a complete entry (empty interrupted-run leftovers are deleted and re-downloaded — this also fixes the unguarded manifest read after a cache hit in `install-mops-dep.ts`); `syncLocalCache` restores packages missing from the global cache before copying (and no longer throws `undefined` when the failure isn't ncp's array-of-errors shape); the advisory requirements check falls back to the global cache instead of crashing. - **Comment corrections**: `computeLockFile` is no longer only-safe-when-stale, and `checkLockConsistency`'s walk-free rationale is now "offline and cheap", not "the walk would crash". Not ported, deliberately: the github fetch-on-miss from #712 (v3 dropped nested-config reads for github deps entirely, so there is nothing to read), the `--lock update` force plumbing from #714, and its hedged mismatch diagnostic (v3's maintain flow has no post-regeneration local verification to hedge; `--locked`'s existing hints are already correct). Fetch-on-miss downloads on v3 additionally go through `verifyDownloadedPackageFiles`, so repaired cache entries are integrity-checked against the registry before they land — stronger than the 2.x port. Tests are the four main-line regression scenarios re-expressed in v3 semantics (no `--lock` flags; carry-over is observed via `mops add` + `mops verify` + delete-lock recovery). All four fail on unfixed v3 code by construction — the graph test with the documented ENOENT, the carry-over test because v3 refetched all hashes. Suites: cache-resilience 4/4, cli.test.ts + local-path-lock + requirements 36/36, typecheck/eslint/prettier clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.
Commands that regenerate a stale
mops.lock—mops add,update,sync,remove— crashed with a raw NodeENOENT: no such file or directory ... mops.tomlwhenever the project's dependency graph contains version conflicts and the previous install was lock-driven.Root cause: installing from a valid lock deliberately downloads only the versions that won a conflict, but regenerating a stale lock re-walks the graph and reads every declared version's manifest from the global cache — including the losers that were never downloaded. Validation (
--locked) was made walk-free earlier on this line, but regeneration was not, so the firstmops addafter a lock-driven install opened a manifest that could not exist. The crash evaded tests because a plain no-lockmops installinstalls the full graph recursively, so the cache always happened to contain the losers.The fix makes the cache self-healing at every read site instead of assuming install left the right state on disk:
resolve-packages.ts): a registry manifest missing from the cache is downloaded before it is read; if the download fails the command reports which package could not be fetched. Uncached GitHub deps are also fetched, so their transitive deps are no longer silently dropped from resolution.isDepCachedrequires a complete entry (cache.ts): registry packages must containmops.toml, GitHub packages must be non-empty. Leftover empty dirs from interrupted pre-staging runs (or a bad shared cache volume) are deleted and re-downloaded instead of counting as hits — this also fixes the same ENOENT atinstall-mops-dep.ts's post-hit manifest read..mopssyncing restores missing packages (sync-local-cache.ts): a resolved winner absent from the global cache is downloaded before the copy, and a real error surfaces instead ofthrow undefined(the old catch assumed ncp's array-of-errors shape).check-requirements.ts): the advisory moc/lintoko minimum-version check falls back to the global-cache manifest and skips the package rather than crashing.Before (second machine with a committed lock, after
mops install):After:
Both regression tests were verified to fail with this exact ENOENT signature before the fix. They isolate the global cache via
XDG_CACHE_HOME, so they don't disturb the developer's real cache.What is unchanged
The alternative fix — having
add/update/syncreinstall the full graph before regenerating the lock — is intentionally not taken: fetch-on-miss repairs exactly the gap and keeps those commands fast. Conflict-winner resolution, lock format, and--lockedsemantics are untouched; no command-line surface changes, so no docs updates.🤖 Generated with Claude Code