Skip to content

fix(cli): survive incomplete global-cache state instead of crashing with ENOENT - #712

Merged
Kamirus merged 1 commit into
mainfrom
kamil-claude/v3-mops-fragilities-3dc0ea
Aug 12, 2026
Merged

fix(cli): survive incomplete global-cache state instead of crashing with ENOENT#712
Kamirus merged 1 commit into
mainfrom
kamil-claude/v3-mops-fragilities-3dc0ea

Conversation

@Kamirus

@Kamirus Kamirus commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Commands that regenerate a stale mops.lockmops add, update, sync, remove — crashed with a raw Node ENOENT: no such file or directory ... mops.toml whenever 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 first mops add after a lock-driven install opened a manifest that could not exist. The crash evaded tests because a plain no-lock mops install installs 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:

  • Fetch-on-miss in the graph walk (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.
  • isDepCached requires a complete entry (cache.ts): registry packages must contain mops.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 at install-mops-dep.ts's post-hit manifest read.
  • .mops syncing 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 of throw undefined (the old catch assumed ncp's array-of-errors shape).
  • Requirements checks are non-fatal (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):

$ mops add base@0.16.0
Checking integrity...
Error: ENOENT: no such file or directory, open '~/.cache/mops/packages/core@2.6.1/mops.toml'

After:

$ mops add base@0.16.0
Package installed base = "0.16.0"

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/sync reinstall 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 --locked semantics are untouched; no command-line surface changes, so no docs updates.

🤖 Generated with Claude Code

…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>
@github-actions

Copy link
Copy Markdown
Contributor

Cursor AI review

👍 APPROVE — looks safe to merge

Category Assessment Details
Summary Fixes ENOENT crashes when regenerating a stale lock after a lock-driven install by fetch-on-miss in resolvePackages, tightening isDepCached completeness, restoring missing packages in syncLocalCache, and soft-failing advisory checkRequirements.
Code Quality Reuses installMopsDep / installFromGithub with ignoreTransitive: true at the miss sites instead of new download helpers; changelog under ## Next matches the user-facing fix.
Consistency Error wording and silent-install options match existing install paths; no CLI flag/mops.toml surface change, so docs/skills omission is appropriate.
Security Traced downloads only through existing installMopsDep / installFromGithub (same registry/GitHub extractors); no auth, integrity-hash, or lock-format changes — checkLockFileLight early-return and winner selection in collectDeps unchanged.
Tests cli/tests/cache-resilience.test.ts isolates cache via XDG_CACHE_HOME, reproduces lock-driven winner-only cache + mops add and empty-dir miss; targeted assertions (not snapshots) fit corner-case guidance; fixture correctly keeps root core@1.0.0 over nested core@2.6.1.
Maintainability Self-healing at read/sync sites is localized; syncLocalCache catch now handles non-array rejects (throw undefined fix) without broadening behavior.

Verdict

Decision: APPROVE
Risk: Low
Reason: High-risk paths are touched, but behavior matches the already-correct complete-cache walk: winners, lock format, and --locked short-circuit are unchanged; the PR only fetches missing manifests/packages that a full install would already have populated, with focused regression coverage.


Generated for commit cddcf1a

@automation-sa-sre automation-sa-sre left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: the AI review verdict for cddcf1a is APPROVE. See the "Cursor AI review" comment for details.

@Kamirus
Kamirus merged commit a46e5fc into main Aug 12, 2026
31 checks passed
@Kamirus
Kamirus deleted the kamil-claude/v3-mops-fragilities-3dc0ea branch August 12, 2026 06:57
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>
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.

2 participants