@adhisang/minecraft-modding-mcp 7.0.0-rc.2 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,53 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [7.0.0] - 2026-09-13
11
+
12
+ ### Fixed
13
+
14
+ - Several more ways a fault during the stdio supervisor's own fault-recovery work could leave a request permanently unanswered, or answered twice, are closed. A `validate-project` request could be left marked as still running forever if an internal recovery step for an earlier fault on that same request itself failed, blocking every later `validate-project` call for the rest of the session; that is now cleared first. If a client re-sent `initialize` to a worker that was already ready and the supervisor then faulted while recovering from that duplicate handshake, other requests still in flight on the worker being replaced were not answered and could hang for the rest of the session; they are now answered before the worker is replaced. And a request taken from the internal queue whose own reply step then failed in a narrow way could be left with no reply at all, or, in a different case, answered a second, spurious time; exactly one reply is now produced either way. Each of these requires an internal fault that is not known to occur in current Node.js and has not been observed in production.
15
+ - Two remaining internal call sites that build an error's logged description now use the same safe string conversion already used elsewhere for this reason, so a thrown value whose own string conversion itself throws can no longer slip past them either. Cosmetic; there is no known way to trigger it.
16
+ - Four more ways a fault on the stdio connection to the worker process could leave a request unanswered are closed. A write error to the worker other than a broken pipe used to be logged and otherwise ignored, leaving the connection pointed at a worker nothing could send to again, so every request already sent to it — and every request sent afterward — hung for the rest of the session; that fault now retires the broken worker and starts a replacement, answering the stranded request along the way. If that same fault lands while a still-starting successor worker is replaying the client's `initialize` handshake, the client's `initialize` call now fails immediately instead of waiting silently across every later restart attempt. A successful reply to `initialize` could also be lost outright if writing it to the client failed at the same moment as the fallback log message the supervisor writes about that failure; both are now recovered from together. And once `initialize` completes, a client may legally reuse its request id for a later call; if the worker then exited before answering that reused id, it used to be mistaken for the already-finished `initialize` and silently dropped instead of receiving its own reply.
17
+ - A large request sent in line-delimited framing right after a `Content-Length`-framed one was wrongly rejected as an oversized header, because the 8 KiB header-size ceiling kept applying even once the reader had already moved on to line framing; such a request is now accepted up to the ordinary frame-size limit. Separately, when an oversized line arrived without its terminating newline yet, discarding it did not also consume the rest of that same line once the newline did arrive; the tail could then be re-read as if it were a fresh, legitimate request of its own. That tail is now discarded along with the rest of the line it belongs to.
18
+ - Deleting a downloaded-jar entry with `manage-cache` could destroy a different jar than the one that was listed, when another resolve finished writing a fresh copy to the same cache slot in between; the delete now verifies it is still removing the jar it originally listed. A delete that could not actually remove a file — for example because the cache directory is read-only — is no longer reported as successful; it now returns a warning and the entry is not counted as deleted. Checking a cached download's size no longer treats a permission error the same as a missing file; previously that silently triggered a fresh download instead of surfacing the actionable error.
19
+ - `find-class` now resolves the correct fully qualified name for a class nested two or more levels inside another class. It previously dropped every enclosing type past the first level, producing a name that does not exist among the compiled classes, so a follow-up call using that name — reading its members or source, for example — failed with a spurious not-found error. A class nested exactly one level deep was already correct and is unaffected.
20
+ - An unrecognized failure opening the artifact index database — for example the database being locked or the disk being full — is now reported as the documented database-failure error instead of escaping unclassified. Previously only a failure already recognized as an I/O problem, a schema mismatch, or corruption was reported that way.
21
+ - Loading a Yarn/Tiny mapping file from a Maven-hosted jar is now bounded the same way nested-jar and download extraction already are. A `.tiny` entry inside such a jar could previously be decompressed in full with no size limit, so a small, highly compressed entry could expand to multiple gigabytes and exhaust memory before mapping parsing ever started.
22
+
23
+ ## [7.0.0-rc.3] - 2026-09-07
24
+
25
+ ### Changed
26
+
27
+ - **A jar named by path, a Minecraft version, or a workspace now takes its `artifactId` from a sha256 of the identified jar's bytes**, not from the jar's path plus its modification time and size. 7.0.0-rc.1 and 7.0.0-rc.2 moved the Maven-coordinate routes onto content hashing; this release moves the rest. `target.kind="version"` and `target.kind="workspace"` resolve through the same jar route, so the rule covers Minecraft's own runtime jar as well as a jar you name, and the lightweight artifact probe behind `validate-project task="project-summary"` follows it too. A `touch`, a cache eviction followed by a re-fetch of byte-identical bytes, or a restore from backup therefore no longer mints a new id or forces a re-decompile. Three bounds. The jar's symlink-resolved path is still part of the id, so two byte-identical jars at two different real paths remain two artifacts. Within one server process a file's digest is reused while its size, modification time, inode and inode-change time all still match, so on a filesystem that does not advance the inode-change time on a write, an in-place replacement of the same length whose modification time was restored can be served the earlier digest until that memo entry is evicted. And one pre-existing limit is untouched: when a Loom split-source pair is indexed together, the artifact's signature comes from the primary sources jar only, so a change confined to the companion jar is not represented in the id.
28
+ - **Upgrade cost of the identity change above.** Every artifact reached through `target.kind="jar"`, `"version"` or `"workspace"` — the Minecraft runtime included — gets a new `artifactId` on its first resolve after upgrading and re-indexes there, with a fresh decompile wherever the decompile branch is reached. There is deliberately no migration shim. An `artifactId` you already hold keeps resolving out of the index and does not re-index. The decompiled and remapped outputs stored under the previous ids are not reachable from the new ones, and nothing reclaims them as part of the upgrade; they stay visible to `manage-cache` under the `decompiled-source` and `binary-remap` cache kinds. Nothing bounds that first call end to end — see the upgrade note under Documentation — so on a large jar it can surface as a client-side request timeout rather than as a slow success.
29
+ - **Wire contract change.** The `artifactSignature` field returned by the `mc://artifact/{artifactId}` resource is now a bare 64-character sha256 hex digest, or that digest followed by `:decompile`. For an artifact reached through a jar, version or workspace target it was previously `<mtimeMs>:<size>`, so a client matching that shape — anything keyed to a digits-colon-digits pattern — stops matching. The new shape applies to artifacts resolved after upgrading; an artifact recorded before the upgrade and reached by an `artifactId` you still hold returns the signature it was stored with, so a client that keeps old ids must accept both shapes. Artifacts reached through a Maven coordinate already published a bare digest and are unchanged. This is the only place the field reaches a client: no tool response carries it, and `resolve-artifact` and `manage-cache` never did.
30
+ - **Behaviour change — two stdio header shapes that used to frame now end the session.** A `Content-Length` header block followed by an extra empty line is malformed, because the declared length does not count that line. Previously two of the four such shapes — an LF-terminated header line closed by a CRLF empty line, followed by either another CRLF or another LF — happened to skip past the extra line and framed correctly. All four now read alike: the body window opens one or two bytes early, by the length of the extra line's own terminator, so the JSON does not parse, and a body-parse failure is framing-fatal, so the session ends and the frames behind it are lost. The other two shapes already failed this way. One case still slips through in either direction: the window is shifted rather than mis-sized, so a declared length that happens to count enough trailing whitespace absorbs the shift and the frame is delivered normally.
31
+
32
+ ### Fixed
33
+
34
+ - Cancelling a queued request that reuses a running `validate-project`'s JSON-RPC id no longer lets other requests run on the worker alongside that `validate-project`. A `notifications/cancelled` for such an id matched the queued entry and, on the id match alone, released the dispatch barrier the running `validate-project` was holding, so other work was admitted beside it. Cancellation now releases the barrier only for a queued request that raised it itself; 7.0.0-rc.2's rule that a request cannot release a barrier it does not hold now covers cancellation too. Nothing could be stranded by this: the barrier was wrongly released, never wrongly left standing.
35
+ - Two identity bugs on symlinked paths are fixed. The artifact probe behind `validate-project task="project-summary"` and `resolve-artifact` now agree on the `artifactId` of a runtime jar reached through a symlinked directory. The probe composed its id from the path as handed to it while `resolve-artifact` resolved the symlink first, so `tasks["minecraft.artifact.resolved"].artifactId` and `resolve-artifact` reported two different ids for one jar; both now resolve the path first, and the probe's id changes for such jars. Separately, when a jar named by path carries no Java sources and its `<basename>-sources.jar` sibling supplies them, and that sibling is itself a symlink, its id now follows the real path. The `sourceJarPath` the response publishes is unchanged.
36
+ - A stdio peer that ends its `Content-Length` header lines with a bare LF but closes the block with a CRLF empty line can now be read at all. That shape matched neither of the two terminator patterns the reader looked for, so the block was never recognized: the reader accumulated until it hit the 8 KiB header ceiling and then ended the session, losing that request and every request behind it. A header block now ends at its first empty line whichever way the lines are terminated, so all four combinations frame, and a `\r\n\r\n` sequence inside a JSON body — legal whitespace — can no longer be mistaken for the terminator. This removes that stall, not the framing-fatal class as a whole: two header shapes that used to frame by accident now fail, which is the behaviour change recorded under Changed.
37
+ - Several ways a request could end up with no reply, or with two, are closed in the stdio supervisor. A request rejected because the queue was full now lowers the `validate-project` dispatch barrier it had raised, instead of leaving it standing with nothing able to lower it and nothing behind it able to dispatch. A fault while handling a worker's response, a fault inside the admission recovery itself, and a dispatch that fails while the queue drains are each now answered with a JSON-RPC `-32603` on the request's own id — previously the first two escaped into the frame reader, which reported a parse error and dropped the frame, and the third stranded the request and parked everything behind it; because only `validate-project` arms a deadline, any other request lost that way waited on its id for the life of the session. A missing pending entry is no longer read as proof the client was answered: when a fault interrupts the supervisor between dropping its own record of the request and handing the reply to the client, the id is still answered; a plain failed write of a reply is still only logged. A rollback that itself fails no longer lets a second terminal reply go out for one id. An in-flight `initialize` whose answer is lost to a fault now restarts the handshake against a replacement worker, where before it could wait indefinitely. And an error object whose own `toString` throws can no longer slip past these guards.
38
+ - Two limits on the repairs above, stated because a user can reach them. When the supervisor's cleanup after such a fault cannot prove the request was removed, it reports and stands down rather than risk two replies for one id; only `validate-project` carries a deadline of its own, so any other request left in that state waits for the worker to exit. And the record that discards a worker's late answer for an already-settled id holds at most 1024 ids at a time — a pre-existing cap this release does not change — so beyond that the oldest is evicted and a very old worker's late answer for an evicted id would pass through.
39
+
40
+ ### Performance
41
+
42
+ - A repository's definitive rejection of a URL — 403, 404 or 410 — is now remembered, so resolving the same target again does not re-issue the requests every configured repository already refused. Previously nothing about a failed download was recorded: only bytes land in the download cache, so a coordinate whose sources jar does not exist paid the full repository sweep on every resolve, and a caller retrying after a timeout paid the identical sweep again. The bounds are part of the feature. The records live in memory only and are lost on restart. Each expires after about five minutes. At most 1024 records are kept at once; past that, expired records are dropped first and then the oldest, so a record can be lost before its five minutes are up. Only those three statuses are eligible: a 5xx, a 429, a 401, a request timeout and a network error are never remembered and are always retried. A `-SNAPSHOT` coordinate is never recorded — it is the one version shape this server treats as mutable — since its absence can be a publish in progress. And the records are kept per cache directory, so two configurations pointing at different cache directories never inherit each other's verdicts.
43
+
44
+ ### Documentation
45
+
46
+ - **Correction to `docs/tool-reference.md` as shipped in 7.0.0-rc.2.** That release corrected the "Inspecting a Fabric / loader dependency like vanilla" target-shape table's claim that dependency members always come back with `qualityFlags: ["dependency-mapping-unverified"]`, but left the identical claim standing in the prose above it. The prose now names both branches: for a non-obfuscated mapping request, a source-backed jar that can honor it returns `mappingApplied` equal to the request and `qualityFlags` including `"source-backed"`; every other dependency jar answering that same request returns `mappingApplied: "obfuscated"` and `qualityFlags` including `"dependency-mapping-unverified"`, with names taken verbatim from the jar's compiled names. A request that asks for `"obfuscated"` outright never reaches that branch: it passes through as `mappingApplied: "obfuscated"` with `"source-backed"` or `"decompiled"`, never with `"dependency-mapping-unverified"`.
47
+ - `docs/tool-reference.md`'s "which requests count as tool-chosen" list in the error-classification section named `get-class-members`, `batch-class-members`, and `inspect-minecraft`'s `class-members` task but omitted `verify-mixin-target`, which reads its target's members from bytecode under the same rule and carries the same per-site `issueOrigin` override. The list now names all four.
48
+ - **Correction to `docs/tool-reference.md` as shipped in 7.0.0-rc.2.** The environment-variable section documented byte-derived jar identity only for jars downloaded through `MCP_SOURCE_REPOS`. It now also records that jars served from `MCP_LOCAL_M2` and the Gradle dependency cache are identified from their bytes the same way whenever reached through a Maven coordinate. The section now also documents the byte-derived rule for jar-path, version and workspace targets, which this release introduces (see Changed).
49
+ - **Correction to `docs/tool-reference.md` as shipped in 7.0.0-rc.2.** The legacy-era exceptions bullet claimed the advertised `inputSchema` bytes are "identical to the pre-migration snapshots" without qualification. The wording now says what the guarantee covers: the bytes `tools/list` advertises for legacy-era tools come from frozen pre-migration contracts, and those contracts are deliberately extended when a tool gains a parameter in a later release — as `projectPath` was added to four tools in 7.0.0-rc.2 itself. The parity guarantee was never broken; only its description was imprecise about what it promises.
50
+ - **The 7.0.0-rc.2 section was corrected in place.** New practice: a false statement in a released section is now amended where readers find it, not merely annotated from a later section. Two claims in its Maven-coordinate hardening entry were wrong. First, "naming the offending segment in `details.component`, including when a mandatory segment is blank": no `details` object reaches a client, and the message names the segment only for a character-rule rejection — a blank segment or a wrong-shaped coordinate names none — so it now reads "whose message names the offending segment when a segment breaks the character rule"; its `dependency`-route `fieldErrors` half is untouched. Second, "no write location was ever reachable, since every cache and artifact write derives its filename from a sha256": that reason is false server-wide — the mapping caches name files from a version string — so only the reason changed, to "because nothing on these routes writes to a coordinate-derived path"; the conclusion stands — those version-derived paths are written only after a successful fetch from a fixed upstream, which a traversal-bearing version cannot reach. Both sentences carry a trailing `(Corrected 2026-09-05; see [Unreleased].)`; the record they point to was written under `[Unreleased]` and now lives in this entry. The published 7.0.0-rc.2 npm tarball ships the changelog as it was released and cannot be amended, so a reader of the copy installed from npm still sees the original wording.
51
+ - **The 7.0.0-rc.0 section was corrected in place.** Its "Ending the session no longer leaves a worker process behind" entry said the signal path and the supervisor-fault path both "exit non-zero once the worker group is collected or a bounded watchdog expires". That is false for the signal path: `SIGHUP` runs the ordinary cooperative shutdown and the process exits with code 0 once the detached worker group is collected, exactly as when stdin closes; the non-zero code belongs to the uncaught-supervisor-fault path, which is how a launcher tells a crash from a clean stop. That entry now states the two exit codes separately and carries a trailing `(Corrected 2026-09-05; see [Unreleased].)`. Correcting it also falsified the closing clause of 7.0.0-rc.2's "Correction to 7.0.0-rc.0." note — "the published 7.0.0-rc.0 entry is left as it stands" — so that clause now records the rc.0 entry as corrected in place and carries the same marker. The published 7.0.0-rc.0 and 7.0.0-rc.2 npm tarballs ship their changelogs as released and cannot be amended, so a reader of either installed copy still sees the original wording.
52
+ - **Upgrade note for 7.0.0-rc.2.** That release said each already-cached local dependency re-indexes once on its first resolve after upgrading, but only as a cost. It can be slow enough to matter: a consumer measured a 95.5-second first `get-class-members` call. Nothing bounds that first call end to end: individual stages carry their own budgets, but no deadline covers the call as a whole, and only `validate-project` has one. Stage progress is emitted by `validate-project` and `validate-mixin` alone, as the custom `$/stageUpdate` notification rather than `notifications/progress`, so it cannot reset a client's timeout even where it is emitted. Whether it returns or is cut off rests on the client's own request timeout, which this server neither sets nor observes. What a retry saves: the artifact index is SQLite and survives the timeout and a restart, so a retry addressed at the `artifactId` skips the re-index. A retry addressed at the same `target` skips the re-index too, and now also skips the repository sweep, but only within the bounds of the remembered rejections under Performance — in-process, about five minutes, definitive rejections only — and the sweep is paid again after a restart or once that window closes. The jar-identity change in this release adds one re-index, and where the decompile branch is reached a fresh decompile, to that same unbounded first call — for the Minecraft runtime as well as local dependencies. The 95.5-second measurement itself is unexplained.
53
+ - `docs/tool-reference.md` now describes artifact identity as it works. Its environment-variable section states the byte-derived rule for jar-path, version and workspace targets alongside the coordinate routes, names which file each jar-route branch hashes, and records both bounds: the symlink-resolved path is still part of the id, and a file's digest is memoized within a process against its size, modification time, inode and inode-change time. The `mc://artifact/{artifactId}` row states the shape of the `artifactSignature` it returns. The `validate-project` task table and `README.md` both record that the artifact probe reads the runtime jar's bytes to derive the `artifactId` it reports.
54
+ - `docs/tool-reference.md` now documents the remembered definitive rejections in its repository section, with the bounds on them: the eligible statuses, the roughly five-minute expiry, the `-SNAPSHOT` exclusion, the per-cache-directory keying, and the fact that the records are lost on restart.
55
+ - `docs/tool-reference.md`'s Framing section now says where a `Content-Length` header block ends, that all four terminator combinations delimit one, and what an extra empty line after the terminator costs — including that the resulting body-parse failure is framing-fatal, and that a declared length counting enough trailing whitespace can absorb the shift instead. Its synthetic-terminal-response section now names the supervisor's internal-fault `-32603` replies alongside the queue-overflow, worker-restart, timeout and startup-failure shapes, and records the one case in which the supervisor deliberately sends nothing at all.
56
+
10
57
  ## [7.0.0-rc.2] - 2026-09-04
11
58
 
12
59
  ### Added
@@ -34,7 +81,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
34
81
  - Cached artifact bytes that a concurrent resolve or cache prune deleted are now re-downloaded instead of failing the call with a raw `ENOENT`. Both a cache hit and a `304` revalidation treat vanished bytes as a cache miss and transfer again — unconditionally in the 304 case, carrying no validator from any source, caller-supplied `requestHeaders` included. A 304 revalidation no longer overwrites a fresher freshness record that a concurrent resolve left beside replaced `-SNAPSHOT` bytes: that record's digest, `etag` and `lastModified` are reported as they stand and no extra transfer is made. A cached entry that is present but unreadable — a permission error, a directory where a jar should be — is now reported instead of being treated as a miss, which had put that URL on the network on every call while hiding the actionable error. When that failed every repository, the final `ERR_REPO_FETCH_FAILED` names the offending cache path and its errno (`EACCES`, `EISDIR`, `EPERM`, `EIO`) in `hints` and says to remove or repair it.
35
82
  - Evicting a poisoned download no longer destroys a concurrent resolve's good jar, and an eviction that cannot delete no longer leaves an entry the cache re-adopts forever. A response that is not a readable archive is dropped from the download cache, but the deletion named only the path, so when another resolve of the same URL had meanwhile finished a real jar into the same slot, that jar was deleted on the strength of a check run against different bytes. The eviction now compares the digest of the body it is rejecting with the identity record beside the file and leaves the file alone when they differ; this narrows the window rather than closing it, since another process can still replace the file between the comparison and the deletion. An entry no record can identify is still deleted, so a cache poisoned by an earlier version still heals itself. Separately, when the deletion itself fails — a read-only cache directory, say — the file is now truncated to zero bytes, which the cache treats exactly like a missing one, so the next transfer replaces it; previously whatever survived was served back and rejected again on every later resolve.
36
83
  - `Retry-After` is now honoured in the RFC 9110 HTTP-date form. That form used to parse as `NaN` and fall through to a roughly 200-millisecond backoff, hammering a server that had just asked for a long pause. The numeric form is now read strictly, so `Retry-After: 12abc` is malformed rather than 12 seconds. Both forms stay capped at 30 seconds, a date already in the past behaves like `Retry-After: 0`, and only the space and horizontal tab RFC 9110 permits are stripped: a value padded with a newline, a form feed or a non-breaking space stays malformed instead of being repaired into a pause. The delta is measured against the local clock rather than the response's `Date` header.
37
- - A caller-supplied Maven coordinate can no longer reach a jar outside the configured local repository. Every segment of `group:artifact:version[:classifier]` becomes a path component, and none was validated: an `artifactId` or `version` carrying `../` walked out of the repository root, and a `groupId` of `.`, `..` or `.a` landed at the filesystem root. Each segment is now trimmed and checked against one rule — 1 to 200 characters from `[A-Za-z0-9._+-]`, no leading `.`, no `..` — with `version` and `classifier` additionally admitting a space, because `net.fabricmc:yarn:1.14 Pre-Release 1+build.10:v2` is a real coordinate. A rejected coordinate fails with `ERR_COORDINATE_PARSE_FAILED` naming the offending segment in `details.component`, including when a mandatory segment is blank; `g : a : 1.0` now resolves instead of failing on its padding. The `dependency` target route applies the same rule to `target.group` and `target.name` before it probes the Gradle cache (its old blocklist admitted `group="D:"` with `name="."`, which listed a directory outside the cache root into `candidatesSeen`), trims `target.version`, and names `target.group` or `target.name` in its field error instead of just `target`. Scope: the reachable impact was reading a file that is both named `<artifact>-<version>[-classifier][-sources].jar` and openable as a zip; no write location was ever reachable, since every cache and artifact write derives its filename from a sha256.
84
+ - A caller-supplied Maven coordinate can no longer reach a jar outside the configured local repository. Every segment of `group:artifact:version[:classifier]` becomes a path component, and none was validated: an `artifactId` or `version` carrying `../` walked out of the repository root, and a `groupId` of `.`, `..` or `.a` landed at the filesystem root. Each segment is now trimmed and checked against one rule — 1 to 200 characters from `[A-Za-z0-9._+-]`, no leading `.`, no `..` — with `version` and `classifier` additionally admitting a space, because `net.fabricmc:yarn:1.14 Pre-Release 1+build.10:v2` is a real coordinate. A rejected coordinate fails with `ERR_COORDINATE_PARSE_FAILED`, whose message names the offending segment when a segment breaks the character rule; `g : a : 1.0` now resolves instead of failing on its padding. The `dependency` target route applies the same rule to `target.group` and `target.name` before it probes the Gradle cache (its old blocklist admitted `group="D:"` with `name="."`, which listed a directory outside the cache root into `candidatesSeen`), trims `target.version`, and names `target.group` or `target.name` in its field error instead of just `target`. Scope: the reachable impact was reading a file that is both named `<artifact>-<version>[-classifier][-sources].jar` and openable as a zip; no write location was ever reachable, because nothing on these routes writes to a coordinate-derived path. (Corrected 2026-09-05; see [Unreleased].)
38
85
  - One corrupt `-sources.jar` in `~/.m2` no longer aborts artifact resolution. A truncated or non-zip sources jar among the resolution candidates used to surface as a generic `ERR_ARTIFACT_RESOLUTION_FAILED`, with the Gradle cache, the remote sources repositories, the local-binary decompile branch and the remote binary fallbacks never tried. An unopenable candidate is now treated as "no sources here" and resolution moves on, as the binary side already did. A jar the caller named directly still reports the open failure rather than laundering it into "no sources".
39
86
  - A sources jar found in `~/.m2` or the Gradle module cache now picks its binary companion by opening the candidates, and looks in both stores. Previously each store took the first companion that merely existed, so a truncated or half-copied jar in one cache shadowed a good copy in the other, and the unusable path was what got recorded on the artifact, so every later `get-class-members` on that `artifactId` went to the broken file. Candidates are now tried in preference order, the sources jar's own store first, and the first one that opens wins. A companion that opens but holds no `.class` entry carries the `binary-jar-no-classes` quality flag here too.
40
87
  - `validate-access-widener` and `validate-access-transformer` no longer answer `ERR_CONTEXT_UNRESOLVED` because of a directory name above the Gradle cache. A runtime jar's loader was read from its whole path, so a Fabric workspace checked out under a folder called `forge`, `moddev-notes` or `srg-test` was taken for a Forge or NeoForge runtime and refused. The loader is now read only from the part of the path a build tool wrote — everything below the deepest dot-directory (`.gradle`, `.m2`), Gradle's `caches` root or a project `build` directory, or the file name alone when the path enters none of them. A NeoForge-patched jar inside a Loom cache is still reported as NeoForge.
@@ -59,7 +106,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
59
106
 
60
107
  - `docs/tool-reference.md` no longer tells a caller to work around a parameter that now exists. Its "Essential Conventions" entry named `find-class` as the only flat-`artifactId` tool accepting a top-level `projectPath` and directed callers of the other four to resolve the artifact first, contradicting the "Common Pitfalls" entry in the same document. Both now state that all five tools accept a top-level `projectPath` for a target that cannot supply workspace context from its own fields; resolve-first stays documented in "Common Pitfalls" as the optional alternative it still is.
61
108
  - `docs/tool-reference.md` now documents the `context` block `get-class-members` and `batch-class-members` return. `jarSignature` is described as a signature over the jar's resolved path, modification time and size — good for spotting that the jar behind two responses is no longer the same file in the same state, usable neither as a content identity nor as a cache key. `minecraftVersion`, `mappingType`, `mappingNamespace` and `generatedAt` are described alongside it, including that `minecraftVersion` is `"unknown"` for a dependency artifact and for a jar inside a dependency store whose store-relative path does not name the Minecraft runtime, while a jar outside any such store is read from the first version-shaped token in its path.
62
- - **Correction to 7.0.0-rc.0.** That release's "Ending the session no longer leaves a worker process behind" entry says the signal path and the supervisor-fault path both "exit non-zero once the worker group is collected or a bounded watchdog expires". That is wrong about the signal path: `SIGHUP`, `SIGINT` and `SIGTERM` run the ordinary cooperative shutdown and the process exits with code 0 once the detached worker group is collected, exactly as when stdin closes. Only an uncaught supervisor fault (`supervisor.fatal`) and a client framing violation (`supervisor.client_framing_fatal`) set exit code 1 on a running session, which is what lets a launcher tell a crash from a clean stop. No code changed; the published 7.0.0-rc.0 entry is left as it stands.
109
+ - **Correction to 7.0.0-rc.0.** That release's "Ending the session no longer leaves a worker process behind" entry says the signal path and the supervisor-fault path both "exit non-zero once the worker group is collected or a bounded watchdog expires". That is wrong about the signal path: `SIGHUP`, `SIGINT` and `SIGTERM` run the ordinary cooperative shutdown and the process exits with code 0 once the detached worker group is collected, exactly as when stdin closes. Only an uncaught supervisor fault (`supervisor.fatal`) and a client framing violation (`supervisor.client_framing_fatal`) set exit code 1 on a running session, which is what lets a launcher tell a crash from a clean stop. No code changed, and the 7.0.0-rc.0 entry has since been corrected in place. (Corrected 2026-09-05; see [Unreleased].)
63
110
 
64
111
  ## [7.0.0-rc.1] - 2026-08-28
65
112
 
@@ -140,7 +187,7 @@ and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2
140
187
  - Tool responses keep their typed error envelopes when SQLite is unavailable: input validation still returns `ERR_INVALID_INPUT`, database-independent tools such as the NBT utilities still return results, and a metrics-recording failure no longer replaces a completed response.
141
188
  - The server recovers from a crashed worker instead of going unresponsive. A worker that hit a fatal `uncaughtException` or `unhandledRejection` used to stay alive with recovery bypassed; it now exits so the supervisor can replace it. A restart blocked by stale process-tree cleanup is retried with capped backoff instead of waiting for shutdown.
142
189
  - The server no longer leaves orphaned processes behind. A keep-alive timer kept the worker's event loop running after stdin closed, so any launcher that terminated the server without a cooperative shutdown left a resident node process — about 125 MB on the reference host — and each restart added another. The worker now stands down on stdin EOF, and also when the parent process it recorded at startup disappears. Replies still being written after a half-close are delivered in full. A host that immediately closes every child's stdin now backs off exponentially instead of respawning a worker ten times a second, while a genuine crash still gets a prompt replacement. Measured on the reference host over one full test run each way: 20 orphaned workers before the fix, 0 after.
143
- - Ending the session no longer leaves a worker process behind. `SIGHUP` — what a terminating launcher or a vanishing session sends — was never handled, and an uncaught error in the supervisor went to node's default handler, so either one left the detached worker process group with nobody to collect it. Both now run the ordinary shutdown path, report a `supervisor.fatal` event where applicable, and exit non-zero once the worker group is collected or a bounded watchdog expires.
190
+ - Ending the session no longer leaves a worker process behind. `SIGHUP` — what a terminating launcher or a vanishing session sends — was never handled, and an uncaught error in the supervisor went to node's default handler, so either one left the detached worker process group with nobody to collect it. Both now run the ordinary shutdown path and collect that group before the process leaves, and the exit code says which of the two happened: `SIGHUP` is an ordinary cooperative stop and exits with code 0, exactly as when stdin closes, while an uncaught supervisor fault reports a `supervisor.fatal` event and exits non-zero once the worker group is collected or a bounded watchdog expires — so a launcher can tell a crash from a clean stop. (Corrected 2026-09-05; see [Unreleased].)
144
191
  - The published package no longer risks shipping internal design documents. `files` listed `docs/**/*.md`, and because an npm `files` allowlist overrides `.gitignore`, a publish from a working tree containing local design notes would have included them. `files` now names the three intended documents explicitly.
145
192
  - The automated npm release workflow can no longer publish a prerelease under the `latest` dist-tag: it derives the dist-tag from the package version, so a SemVer prerelease publishes under `rc` and an install without an explicit tag never picks up a release candidate.
146
193
 
package/README.md CHANGED
@@ -172,7 +172,7 @@ These notes cover high-frequency decisions during onboarding. For the full pitfa
172
172
  - `analyze-mod` and `validate-project` require structured `subject` objects and canonical `include` groups; stale string-subject or domain-include payloads return `ERR_INVALID_INPUT` with a retryable `suggestedCall`.
173
173
  - `validate-project task="project-summary"` propagates `preferProjectVersion=true` across discovered Mixin, Access Widener, and Access Transformer checks. If no version can be resolved from the request or `gradle.properties`, the summary returns recovery guidance instead of guessing.
174
174
  - `validate-mixin` and `validate-project` keep `mapping-health` lightweight for `obfuscated` and `mojang` validation, avoiding full Tiny mapping graph loads unless `intermediary` or `yarn` namespaces are requested.
175
- - `validate-project task="project-summary"` uses a lightweight artifact probe for `tasks["minecraft.artifact.resolved"]`; it does not decompile Minecraft or rebuild the source index just to report per-probe status. Set `VALIDATE_PROJECT_TASKS_OFF=1` to omit the additive `tasks` field.
175
+ - `validate-project task="project-summary"` uses a lightweight artifact probe for `tasks["minecraft.artifact.resolved"]`; it does not decompile Minecraft or rebuild the source index just to report per-probe status. It does read the runtime jar's bytes to derive the `artifactId` it reports, and repeat probes of the same jar in one process reuse that digest. Set `VALIDATE_PROJECT_TASKS_OFF=1` to omit the additive `tasks` field.
176
176
  - `validate-project` has a supervisor-owned end-to-end deadline of 120 seconds, including queue time. Set `MCP_VALIDATE_PROJECT_TIMEOUT_MS` to an ASCII-decimal value from `10000` through `600000` to override it. A timeout returns `ERR_TOOL_TIMEOUT`; a running timeout restarts the isolated worker before queued calls resume, while a queue timeout leaves the current worker untouched.
177
177
  - Artifact downloads are capped at 512 MiB (`536870912` bytes) each. Set `MCP_MAX_DOWNLOAD_BYTES` to an ASCII-decimal byte count to override it; values below 1 MiB are raised to 1 MiB, and anything else — non-numeric, malformed, or too large to be an exact integer — falls back to the default. The cap is checked twice: against `Content-Length` before any body is read (a header that is not a plain byte count is treated as absent rather than as a breach), and against the bytes actually received, so a chunked or mis-declared response is caught too. A download that exceeds it fails with `ERR_LIMIT_EXCEEDED` and is not retried; the partial file is deleted on a best-effort basis, so a file the server cannot remove may survive the refusal. A cached copy is never served in its place — a cap is a configuration verdict, not a passing outage, and standing a stale artifact in for it would hide the ceiling instead of reporting it. When every repository trips the cap, the terminal `ERR_REPO_FETCH_FAILED` reports it where a caller can actually read it: `hints` carries the sentence naming `MCP_MAX_DOWNLOAD_BYTES` and the byte count to clear, and `context.repoFailureCode` is `ERR_LIMIT_EXCEEDED`.
178
178
  - Nested (Jar-in-Jar) entries are capped at 64 MiB (`67108864` bytes) of uncompressed data each. Set `MCP_MAX_NESTED_JAR_ENTRY_BYTES` to override it, on the same rules as the download cap: ASCII decimal digits, values below 1 MiB raised to 1 MiB, anything else falling back to the default. The size is checked against the entry's declared uncompressed size before the read starts and again against the bytes actually streamed. A breach fails that one inner jar with `ERR_LIMIT_EXCEEDED`; the shell jar's other inner jars still resolve.
@@ -0,0 +1,130 @@
1
+ import type { MappingVariant } from "./types.js";
2
+ /**
3
+ * The single owner of artifact identity: what an artifactId is derived from,
4
+ * and how the derived signature is composed into the id.
5
+ *
6
+ * Five call sites produce a jar id - three in `src/source-resolver.ts` and two
7
+ * in `src/source/artifact-resolver.ts` - and every one of them reaches it
8
+ * through {@link jarArtifactIdentity}, so the derivation is written once. The
9
+ * coordinate cascade's five results are composed by {@link composeArtifactId},
10
+ * which is the only place either id layout appears.
11
+ *
12
+ * Both id spaces derive the same way: a sha256 of the bytes of the file the id
13
+ * is about, produced by {@link contentSignature}. Neither composer takes a bare
14
+ * `signature: string`, and the one signature type there is can only be built
15
+ * from a value tagged as a content digest, so nothing cheaper can reach the
16
+ * hash by a route nobody reviewed.
17
+ */
18
+ /** A sha256 of the bytes of the file itself. */
19
+ export interface ContentDigestSignature {
20
+ readonly from: "content-digest";
21
+ readonly value: string;
22
+ }
23
+ export declare function contentDigestSignature(value: string): ContentDigestSignature;
24
+ /**
25
+ * An id for a jar named by its path.
26
+ *
27
+ * The layout is `jar | <path> | <signature> | source`, plus `mojang-remapped`
28
+ * when that variant applies. The trailing `source` is a constant part of the
29
+ * layout with no producer that varies it; it is written out because the digest
30
+ * depends on every part.
31
+ */
32
+ export interface JarArtifactIdSpec {
33
+ readonly space: "jar";
34
+ /** Symlink-resolved, so two names for one file cannot mint two ids. */
35
+ readonly jarPath: string;
36
+ /**
37
+ * A sha256 of the jar's bytes, the same kind of signature the coordinate
38
+ * space takes. Required rather than merely permitted: a producer cannot
39
+ * identify a jar by something cheaper without changing this type.
40
+ */
41
+ readonly signature: ContentDigestSignature;
42
+ /**
43
+ * Appended to the signature after a `:` before hashing. The decompile route
44
+ * is the only user: the same jar read for its sources and the same jar handed
45
+ * to the decompiler are different artifacts and must not share an id.
46
+ */
47
+ readonly signatureQualifier?: string;
48
+ readonly mappingVariant?: MappingVariant;
49
+ }
50
+ /**
51
+ * An id for an artifact reached through a Maven coordinate.
52
+ *
53
+ * The layout is `coord | <coordinate> | <idSource> | <signature>`, plus
54
+ * `mojang-remapped` when that variant applies.
55
+ *
56
+ * The signature is a content digest of the bytes this cascade resolved, the
57
+ * same as the jar space's.
58
+ */
59
+ export interface CoordinateArtifactIdSpec {
60
+ readonly space: "coordinate";
61
+ readonly coordinate: string;
62
+ /**
63
+ * The id space this resolution belongs to, so artifacts discovered along
64
+ * different paths cannot collide in the hash.
65
+ */
66
+ readonly idSource: string;
67
+ readonly signature: ContentDigestSignature;
68
+ readonly mappingVariant?: MappingVariant;
69
+ }
70
+ export type ArtifactIdSpec = JarArtifactIdSpec | CoordinateArtifactIdSpec;
71
+ /**
72
+ * The qualifier the decompile route puts on a jar's signature. Shared so the
73
+ * full resolve and the lightweight probe cannot spell it differently and mint
74
+ * two ids for one decompiled jar.
75
+ */
76
+ export declare const DECOMPILE_SIGNATURE_QUALIFIER = "decompile";
77
+ /** Compose an artifactId. The only place either id layout is written down. */
78
+ export declare function composeArtifactId(spec: ArtifactIdSpec): string;
79
+ export interface JarArtifactIdentity {
80
+ /** The symlink-resolved path the signature was read from and the id composed from. */
81
+ readonly resolvedPath: string;
82
+ /** The signature as published in `ResolvedSourceArtifact.artifactSignature`, qualifier included. */
83
+ readonly signature: string;
84
+ readonly artifactId: string;
85
+ }
86
+ export interface JarArtifactIdentityOptions {
87
+ readonly signatureQualifier?: string;
88
+ readonly mappingVariant?: MappingVariant;
89
+ }
90
+ /**
91
+ * Normalize, derive, compose - in that order, for every jar-route producer.
92
+ *
93
+ * Normalizing first is what makes one jar under two names one artifact: the
94
+ * path that enters the hash is the resolved one, never the one handed in.
95
+ * `tests/source-service/probe-resolve-id-agreement.test.ts` pins that the
96
+ * lightweight probe and a full resolve agree through a symlinked directory.
97
+ *
98
+ * The derivation is {@link contentSignature}, so a jar named by path is
99
+ * identified by what is inside it: moving its mtime without changing a byte
100
+ * leaves the id where it was, and replacing its bytes moves the id.
101
+ *
102
+ * Hashing reads the whole file, so every producer calls this on its own branch,
103
+ * on the one file that branch's id is about, rather than once before the branch
104
+ * is picked. The branch that adopts a sibling `-sources.jar` therefore hashes
105
+ * that sibling and not the binary beside it, and the branch that finds no
106
+ * sources and cannot decompile hashes nothing at all.
107
+ */
108
+ export declare function jarArtifactIdentity(jarPath: string, options?: JarArtifactIdentityOptions): Promise<JarArtifactIdentity>;
109
+ /**
110
+ * The identity of a jar sitting on local disk: a sha256 of its bytes.
111
+ *
112
+ * This is the derivation behind every artifactId this module composes - the
113
+ * coordinate cascade's local legs call it directly, and the jar route reaches
114
+ * it through {@link jarArtifactIdentity}.
115
+ *
116
+ * `~/.m2` and the Gradle module cache move a file's mtime for reasons that have
117
+ * nothing to do with its contents - an eviction followed by a re-fetch of
118
+ * byte-identical bytes, a filesystem restore, a plain `touch`. An `mtimeMs:size`
119
+ * signature turns every one of those into a fresh artifactId and a fresh
120
+ * decompile, which is exactly the instability the download cache's
121
+ * content-addressed identity removed from the remote half of the coordinate
122
+ * cascade.
123
+ *
124
+ * Hashing is not free and that cascade is re-walked on every target-driven tool
125
+ * call, so the digest is memoized against the stat that produced it. The stat is
126
+ * taken *before* the digest on purpose: bytes replaced mid-hash are recorded
127
+ * against a stat they no longer have, so the entry is rejected on the next call
128
+ * and re-derived - a wasted hash, never a wrong identity.
129
+ */
130
+ export declare function contentSignature(jarPath: string): Promise<ContentDigestSignature>;
@@ -0,0 +1,142 @@
1
+ import { statSync } from "node:fs";
2
+ import { stableArtifactId } from "./config.js";
3
+ import { normalizeJarPath } from "./path-resolver.js";
4
+ import { digestFile } from "./repo-downloader.js";
5
+ export function contentDigestSignature(value) {
6
+ return { from: "content-digest", value };
7
+ }
8
+ /**
9
+ * The qualifier the decompile route puts on a jar's signature. Shared so the
10
+ * full resolve and the lightweight probe cannot spell it differently and mint
11
+ * two ids for one decompiled jar.
12
+ */
13
+ export const DECOMPILE_SIGNATURE_QUALIFIER = "decompile";
14
+ function qualifySignature(value, qualifier) {
15
+ return qualifier === undefined ? value : `${value}:${qualifier}`;
16
+ }
17
+ /** Compose an artifactId. The only place either id layout is written down. */
18
+ export function composeArtifactId(spec) {
19
+ const parts = spec.space === "jar"
20
+ // "jar" and "coord" are the tokens that enter the hash. They are not the
21
+ // `space` discriminant values, which only pick the layout.
22
+ ? ["jar", spec.jarPath, qualifySignature(spec.signature.value, spec.signatureQualifier), "source"]
23
+ : ["coord", spec.coordinate, spec.idSource, spec.signature.value];
24
+ if ((spec.mappingVariant ?? "pass") === "mojang-remapped") {
25
+ parts.push("mojang-remapped");
26
+ }
27
+ return stableArtifactId(parts);
28
+ }
29
+ /**
30
+ * Normalize, derive, compose - in that order, for every jar-route producer.
31
+ *
32
+ * Normalizing first is what makes one jar under two names one artifact: the
33
+ * path that enters the hash is the resolved one, never the one handed in.
34
+ * `tests/source-service/probe-resolve-id-agreement.test.ts` pins that the
35
+ * lightweight probe and a full resolve agree through a symlinked directory.
36
+ *
37
+ * The derivation is {@link contentSignature}, so a jar named by path is
38
+ * identified by what is inside it: moving its mtime without changing a byte
39
+ * leaves the id where it was, and replacing its bytes moves the id.
40
+ *
41
+ * Hashing reads the whole file, so every producer calls this on its own branch,
42
+ * on the one file that branch's id is about, rather than once before the branch
43
+ * is picked. The branch that adopts a sibling `-sources.jar` therefore hashes
44
+ * that sibling and not the binary beside it, and the branch that finds no
45
+ * sources and cannot decompile hashes nothing at all.
46
+ */
47
+ export async function jarArtifactIdentity(jarPath, options = {}) {
48
+ const resolvedPath = normalizeJarPath(jarPath);
49
+ const signature = await contentSignature(resolvedPath);
50
+ return {
51
+ resolvedPath,
52
+ signature: qualifySignature(signature.value, options.signatureQualifier),
53
+ artifactId: composeArtifactId({
54
+ space: "jar",
55
+ jarPath: resolvedPath,
56
+ signature,
57
+ signatureQualifier: options.signatureQualifier,
58
+ mappingVariant: options.mappingVariant
59
+ })
60
+ };
61
+ }
62
+ /**
63
+ * Digests already derived from a local jar, each pinned to the stat that
64
+ * produced it.
65
+ *
66
+ * Keyed by the symlink-resolved path, so a jar reached through two names is
67
+ * hashed once. The entry is only ever *reused*, never trusted on its own: it is
68
+ * discarded unless size, mtimeMs, inode and ctimeMs all still match the file on
69
+ * disk. ctimeMs is in that key because it is the field a write moves that
70
+ * `utimes` cannot put back, so a same-size replacement carrying a restored
71
+ * mtime is re-hashed rather than served the digest of the bytes it replaced.
72
+ *
73
+ * Known limitation: that guarantee is only as strong as the platform's
74
+ * inode-change timestamp. Where a filesystem does not advance ctimeMs on a
75
+ * write, this key degrades to size + mtime + inode and a same-size replacement
76
+ * whose mtime was restored is served the recorded digest until the entry is
77
+ * evicted. Nothing here re-reads the bytes to check.
78
+ *
79
+ * Bounded, like the helper caches in `src/source/artifact-resolver.ts`: this map
80
+ * is module-level and lives as long as the process, and a long-running server
81
+ * walks the resolution cascade once per target-driven tool call, so an
82
+ * unbounded map grows with every distinct jar path the server has ever seen.
83
+ * Eviction costs at most one re-hash, which is exactly what a cache miss
84
+ * already costs.
85
+ */
86
+ const contentSignatureCache = new Map();
87
+ const MAX_CONTENT_SIGNATURE_CACHE = 512;
88
+ /** Insert, dropping the oldest key first when the bound is reached. */
89
+ function rememberContentSignature(resolvedPath, entry) {
90
+ if (!contentSignatureCache.has(resolvedPath) && contentSignatureCache.size >= MAX_CONTENT_SIGNATURE_CACHE) {
91
+ const oldestKey = contentSignatureCache.keys().next().value;
92
+ if (oldestKey) {
93
+ contentSignatureCache.delete(oldestKey);
94
+ }
95
+ }
96
+ contentSignatureCache.set(resolvedPath, entry);
97
+ }
98
+ /**
99
+ * The identity of a jar sitting on local disk: a sha256 of its bytes.
100
+ *
101
+ * This is the derivation behind every artifactId this module composes - the
102
+ * coordinate cascade's local legs call it directly, and the jar route reaches
103
+ * it through {@link jarArtifactIdentity}.
104
+ *
105
+ * `~/.m2` and the Gradle module cache move a file's mtime for reasons that have
106
+ * nothing to do with its contents - an eviction followed by a re-fetch of
107
+ * byte-identical bytes, a filesystem restore, a plain `touch`. An `mtimeMs:size`
108
+ * signature turns every one of those into a fresh artifactId and a fresh
109
+ * decompile, which is exactly the instability the download cache's
110
+ * content-addressed identity removed from the remote half of the coordinate
111
+ * cascade.
112
+ *
113
+ * Hashing is not free and that cascade is re-walked on every target-driven tool
114
+ * call, so the digest is memoized against the stat that produced it. The stat is
115
+ * taken *before* the digest on purpose: bytes replaced mid-hash are recorded
116
+ * against a stat they no longer have, so the entry is rejected on the next call
117
+ * and re-derived - a wasted hash, never a wrong identity.
118
+ */
119
+ export async function contentSignature(jarPath) {
120
+ // The same normalization the jar route applies, kept so this path still
121
+ // refuses a vanished or non-jar file the way it always has.
122
+ const resolvedPath = normalizeJarPath(jarPath);
123
+ const stats = statSync(resolvedPath);
124
+ const cached = contentSignatureCache.get(resolvedPath);
125
+ if (cached &&
126
+ cached.mtimeMs === stats.mtimeMs &&
127
+ cached.size === stats.size &&
128
+ cached.ino === stats.ino &&
129
+ cached.ctimeMs === stats.ctimeMs) {
130
+ return contentDigestSignature(cached.sha256);
131
+ }
132
+ const { contentSha256 } = await digestFile(resolvedPath);
133
+ rememberContentSignature(resolvedPath, {
134
+ mtimeMs: stats.mtimeMs,
135
+ size: stats.size,
136
+ ino: stats.ino,
137
+ ctimeMs: stats.ctimeMs,
138
+ sha256: contentSha256
139
+ });
140
+ return contentDigestSignature(contentSha256);
141
+ }
142
+ //# sourceMappingURL=artifact-identity.js.map
@@ -42,6 +42,22 @@ type CacheEntryPage = {
42
42
  * while still allowing major.minor sweeps where "1.21" matches "1.21.4".
43
43
  */
44
44
  export declare function pathContainsVersion(path: string, version: string): boolean;
45
+ /**
46
+ * The identity `deleteEntries` captures for a downloads-cache jar at listing
47
+ * time, so a prune that only ever looked at the file once cannot destroy a
48
+ * concurrent resolve's freshly-written replacement at the same path.
49
+ *
50
+ * A missing, unreadable, or malformed sidecar answers with no identity to
51
+ * prove - {@link discardCachedDownload} already treats that as licence to
52
+ * evict unconditionally, the "cannot prove otherwise" rule this module's own
53
+ * sidecar-less entries already rely on elsewhere.
54
+ *
55
+ * Exported for direct testing, the same way {@link pathContainsVersion} is.
56
+ */
57
+ export declare function readDownloadEntryIdentity(jarPath: string): Promise<{
58
+ url: string;
59
+ contentSha256: string;
60
+ } | undefined>;
45
61
  export type CacheRegistryConfig = {
46
62
  cacheDir: string;
47
63
  sqlitePath: string;
@@ -4,7 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { mapWithConcurrencyLimit } from "./concurrency.js";
5
5
  import { createError, ERROR_CODES } from "./errors.js";
6
6
  import { normalizeOptionalPathForHost } from "./path-converter.js";
7
- import { downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
7
+ import { discardCachedDownload, downloadSidecarPath, isDownloadSidecarPath } from "./repo-downloader.js";
8
8
  import { openDatabase } from "./storage/db.js";
9
9
  import { getProcessWorkspaceContextCache } from "./workspace-context-cache.js";
10
10
  export const PUBLIC_CACHE_KINDS = [
@@ -528,6 +528,33 @@ async function downloadSidecarSizeBytes(downloadPath) {
528
528
  return 0;
529
529
  }
530
530
  }
531
+ /**
532
+ * The identity `deleteEntries` captures for a downloads-cache jar at listing
533
+ * time, so a prune that only ever looked at the file once cannot destroy a
534
+ * concurrent resolve's freshly-written replacement at the same path.
535
+ *
536
+ * A missing, unreadable, or malformed sidecar answers with no identity to
537
+ * prove - {@link discardCachedDownload} already treats that as licence to
538
+ * evict unconditionally, the "cannot prove otherwise" rule this module's own
539
+ * sidecar-less entries already rely on elsewhere.
540
+ *
541
+ * Exported for direct testing, the same way {@link pathContainsVersion} is.
542
+ */
543
+ export async function readDownloadEntryIdentity(jarPath) {
544
+ try {
545
+ const parsed = JSON.parse(await readFile(downloadSidecarPath(jarPath), "utf8"));
546
+ if (typeof parsed.url === "string" &&
547
+ typeof parsed.contentSha256 === "string" &&
548
+ parsed.contentSha256.length > 0) {
549
+ return { url: parsed.url, contentSha256: parsed.contentSha256 };
550
+ }
551
+ }
552
+ catch {
553
+ // Missing, unreadable, or malformed sidecar: nothing to identify these
554
+ // bytes with.
555
+ }
556
+ return undefined;
557
+ }
531
558
  /**
532
559
  * Binary-remap cache entries are keyed by the final artifact id even when the
533
560
  * on-disk entry is a corrupt final directory or a leftover temp path.
@@ -682,7 +709,19 @@ export function createCacheRegistry(config) {
682
709
  async deleteEntries(input) {
683
710
  const entries = await collectEntries(input.cacheKinds, input.selector);
684
711
  const selectedBytes = entries.reduce((total, entry) => total + entry.sizeBytes, 0);
712
+ const warnings = [];
713
+ const failedEntries = [];
685
714
  if (input.executionMode === "apply") {
715
+ // Captured now, against the listing this call just made, rather than
716
+ // re-read right before each unlink below: the identity has to describe
717
+ // what THIS call selected, not whatever a concurrent resolve may have
718
+ // already replaced it with by the time the loop below reaches it.
719
+ const downloadIdentities = new Map();
720
+ for (const entry of entries) {
721
+ if (entry.cacheKind === "downloads") {
722
+ downloadIdentities.set(entry.path, await readDownloadEntryIdentity(entry.path));
723
+ }
724
+ }
686
725
  const db = openDb(config);
687
726
  try {
688
727
  for (const entry of entries) {
@@ -695,12 +734,40 @@ export function createCacheRegistry(config) {
695
734
  continue;
696
735
  }
697
736
  if (entry.cacheKind === "downloads") {
698
- // The sidecar is part of this entry, so it goes with the jar —
699
- // outside the existsSync guard below, so a jar that vanished
700
- // out-of-band since the listing still takes its sidecar with it
701
- // instead of leaving an orphan behind. `force` makes a missing
702
- // sidecar a no-op.
703
- await rm(downloadSidecarPath(entry.path), { force: true });
737
+ // Route through the same sidecar-identity check every other
738
+ // eviction of this shared cache goes through, instead of an
739
+ // unconditional unlink that cannot tell a poisoned jar from a
740
+ // concurrent resolve's good one sitting at the same path. This
741
+ // also retires the sidecar, so it goes with the jar exactly as
742
+ // before - but only when the check above says the jar is still
743
+ // the one this call listed. See discardCachedDownload's doc
744
+ // comment in repo-downloader.ts for what the check does and does
745
+ // not close.
746
+ const expectedIdentity = downloadIdentities.get(entry.path);
747
+ discardCachedDownload(entry.path, expectedIdentity);
748
+ // discardCachedDownload swallows a genuine unlink failure
749
+ // (EACCES, EBUSY, a locked file, ...) - documented there as an
750
+ // accepted limitation for its own best-effort callers. That is
751
+ // wrong for this explicit, user-facing delete/prune request:
752
+ // verify the postcondition instead of trusting the call's
753
+ // silence. The jar surviving is only a LEGITIMATE decline when
754
+ // the identity recorded beside it now genuinely differs from
755
+ // what this call captured at listing time - i.e. a concurrent
756
+ // resolve's replacement is sitting there, exactly the case
757
+ // discardCachedDownload itself declines for. A jar that is
758
+ // still present with no such change (or no readable identity at
759
+ // all) means the removal itself failed.
760
+ if (existsSync(entry.path)) {
761
+ const currentIdentity = await readDownloadEntryIdentity(entry.path);
762
+ const declinedForConcurrentReplacement = expectedIdentity !== undefined &&
763
+ currentIdentity !== undefined &&
764
+ currentIdentity.contentSha256 !== expectedIdentity.contentSha256;
765
+ if (!declinedForConcurrentReplacement) {
766
+ warnings.push(`Could not delete cached download (it may be locked or read-only): ${entry.path}`);
767
+ failedEntries.push(entry);
768
+ }
769
+ }
770
+ continue;
704
771
  }
705
772
  if (existsSync(entry.path)) {
706
773
  // Only binary-remap inventory can return directories as entries;
@@ -713,10 +780,11 @@ export function createCacheRegistry(config) {
713
780
  db?.close();
714
781
  }
715
782
  }
783
+ const failedBytes = failedEntries.reduce((total, entry) => total + entry.sizeBytes, 0);
716
784
  return {
717
- deletedEntries: entries.length,
718
- deletedBytes: selectedBytes,
719
- warnings: []
785
+ deletedEntries: entries.length - failedEntries.length,
786
+ deletedBytes: selectedBytes - failedBytes,
787
+ warnings
720
788
  };
721
789
  },
722
790
  async pruneEntries(input) {
package/dist/config.js CHANGED
@@ -229,8 +229,8 @@ export function buildArtifactAlias(input) {
229
229
  else {
230
230
  tokens.push("jar", aliasJarBase(input.value));
231
231
  }
232
- // mappingVariant is canonical — `artifactIdForJar` / `artifactIdForCoordinate`
233
- // bake "mojang-remapped" into the artifactId, so the same artifactId always
232
+ // mappingVariant is canonical — `composeArtifactId` in `src/artifact-identity.ts`
233
+ // bakes "mojang-remapped" into the artifactId, so the same artifactId always
234
234
  // produces the same alias regardless of how many times resolveArtifact is
235
235
  // called. The token here is purely a human-readable hint; the 12-char hash
236
236
  // suffix would already separate remapped artifacts from pass-through ones.
@@ -3,8 +3,8 @@ import { z } from "zod";
3
3
  export declare const analyzeModShape: {
4
4
  task: z.ZodEnum<{
5
5
  search: "search";
6
- summary: "summary";
7
6
  decompile: "decompile";
7
+ summary: "summary";
8
8
  "class-source": "class-source";
9
9
  members: "members";
10
10
  remap: "remap";
@@ -22,8 +22,8 @@ export declare const analyzeModShape: {
22
22
  class: "class";
23
23
  field: "field";
24
24
  method: "method";
25
- all: "all";
26
25
  content: "content";
26
+ all: "all";
27
27
  }>>;
28
28
  limit: z.ZodDefault<z.ZodNumber>;
29
29
  includeFiles: z.ZodDefault<z.ZodBoolean>;
@@ -51,8 +51,8 @@ export declare const analyzeModShape: {
51
51
  export declare const analyzeModSchema: z.ZodObject<{
52
52
  task: z.ZodEnum<{
53
53
  search: "search";
54
- summary: "summary";
55
54
  decompile: "decompile";
55
+ summary: "summary";
56
56
  "class-source": "class-source";
57
57
  members: "members";
58
58
  remap: "remap";
@@ -70,8 +70,8 @@ export declare const analyzeModSchema: z.ZodObject<{
70
70
  class: "class";
71
71
  field: "field";
72
72
  method: "method";
73
- all: "all";
74
73
  content: "content";
74
+ all: "all";
75
75
  }>>;
76
76
  limit: z.ZodDefault<z.ZodNumber>;
77
77
  includeFiles: z.ZodDefault<z.ZodBoolean>;