@adhisang/minecraft-modding-mcp 7.1.0 → 7.1.1
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 +42 -0
- package/README.md +1 -1
- package/dist/access-transformer-parser.d.ts +8 -0
- package/dist/access-transformer-parser.js +8 -1
- package/dist/access-widener-parser.d.ts +17 -0
- package/dist/access-widener-parser.js +12 -1
- package/dist/cache-registry.js +19 -6
- package/dist/entry-tools/inspect-minecraft/handlers/versions.js +15 -9
- package/dist/entry-tools/manage-cache-service.js +10 -14
- package/dist/entry-tools/validate-project/cases/access-transformer.js +22 -3
- package/dist/entry-tools/validate-project/cases/access-widener.js +31 -4
- package/dist/entry-tools/validate-project/cases/mixin.js +11 -3
- package/dist/entry-tools/validate-project/cases/project-summary.js +24 -5
- package/dist/entry-tools/verify-mixin-target-service.js +19 -8
- package/dist/index.js +1 -0
- package/dist/java-process.d.ts +1 -0
- package/dist/java-process.js +14 -0
- package/dist/mapping/lookup.js +16 -1
- package/dist/mapping-service.d.ts +14 -0
- package/dist/mapping-service.js +35 -15
- package/dist/minecraft-explorer-service.js +70 -8
- package/dist/mixin/access-validators.js +38 -2
- package/dist/mixin/annotation-validators.js +137 -43
- package/dist/mixin/parsed-validator.js +21 -7
- package/dist/mixin-parser.d.ts +52 -0
- package/dist/mixin-parser.js +709 -130
- package/dist/mod-decompile-service.js +11 -1
- package/dist/mod-remap-service.js +6 -6
- package/dist/nbt/java-nbt-codec.js +7 -1
- package/dist/source/access-validate.js +10 -0
- package/dist/source/artifact-resolver.d.ts +13 -3
- package/dist/source/artifact-resolver.js +129 -18
- package/dist/source/class-source.js +13 -2
- package/dist/source/nested-jars.d.ts +15 -1
- package/dist/source/nested-jars.js +14 -5
- package/dist/source/search.d.ts +10 -2
- package/dist/source/search.js +60 -13
- package/dist/source/validate-mixin/pipeline/mapping-health.js +20 -1
- package/dist/source/validate-mixin/pipeline/target-lookup.js +16 -7
- package/dist/source/validate-mixin.d.ts +5 -0
- package/dist/source/validate-mixin.js +136 -21
- package/dist/source/workspace-target.js +75 -7
- package/dist/source-jar-reader.d.ts +15 -1
- package/dist/source-jar-reader.js +35 -3
- package/dist/source-resolver.js +2 -9
- package/dist/stdio-supervisor.d.ts +35 -1
- package/dist/stdio-supervisor.js +77 -2
- package/dist/storage/files-repo.d.ts +7 -0
- package/dist/storage/files-repo.js +17 -4
- package/dist/tool-contract-manifest.js +2 -2
- package/dist/tool-execution-gate.js +2 -1
- package/dist/version-service.js +7 -0
- package/dist/workspace-context-cache.d.ts +25 -0
- package/dist/workspace-context-cache.js +52 -2
- package/dist/workspace-mapping-service.js +116 -14
- package/docs/README-ja.md +1 -1
- package/docs/tool-reference.md +14 -11
- package/package.json +1 -1
package/dist/stdio-supervisor.js
CHANGED
|
@@ -670,6 +670,15 @@ export class StdioSupervisor {
|
|
|
670
670
|
unresolvedTreeTokens = new Set();
|
|
671
671
|
restartBackoff = new RestartBackoffState();
|
|
672
672
|
terminalChildren = new WeakSet();
|
|
673
|
+
/**
|
|
674
|
+
* Workers started by the DEFAULT spawner on POSIX, which spawns them
|
|
675
|
+
* detached — so each one leads its own process group and `-pid` names
|
|
676
|
+
* exactly that group. Only these are group-signalled after an unexpected
|
|
677
|
+
* exit: a child handed in by an injected spawner carries no such guarantee,
|
|
678
|
+
* and its pid may not be a group id this supervisor owns.
|
|
679
|
+
*/
|
|
680
|
+
processGroupLeaders = new WeakSet();
|
|
681
|
+
spawnsProcessGroupLeaders;
|
|
673
682
|
/**
|
|
674
683
|
* Monotonic worker-generation counter (incremented per successful spawn).
|
|
675
684
|
* Recorded into finality tombstones so retention can be bounded per
|
|
@@ -776,7 +785,8 @@ export class StdioSupervisor {
|
|
|
776
785
|
* Number of queued requests that arrived before an initial initialize while
|
|
777
786
|
* the worker was unavailable. Only era-neutral modern discovers can occupy
|
|
778
787
|
* this prefix; readiness forwards it before initialize, then keeps the queue
|
|
779
|
-
* suffix gated until the initialization response.
|
|
788
|
+
* suffix gated until the initialization response. A RE-initialize (the era
|
|
789
|
+
* is already legacy) has no prefix at all: nothing queued may overtake it.
|
|
780
790
|
*/
|
|
781
791
|
initializePredecessorCount = 0;
|
|
782
792
|
clientInitialized = false;
|
|
@@ -818,6 +828,7 @@ export class StdioSupervisor {
|
|
|
818
828
|
this.monotonicNow = options.monotonicNow ?? (() => performance.now());
|
|
819
829
|
this.timerScheduler = options.timerScheduler ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
820
830
|
this.timerClearer = options.timerClearer ?? ((timer) => clearTimeout(timer));
|
|
831
|
+
this.spawnsProcessGroupLeaders = options.workerSpawner === undefined && process.platform !== "win32";
|
|
821
832
|
this.workerSpawner = options.workerSpawner ?? (() => spawn(process.execPath, [...process.execArgv, this.entryFile], {
|
|
822
833
|
env: {
|
|
823
834
|
...process.env,
|
|
@@ -1246,6 +1257,7 @@ export class StdioSupervisor {
|
|
|
1246
1257
|
this.writeToClient(buildInvalidInitializeRejection(message.id), this.modeForMessage(message));
|
|
1247
1258
|
return;
|
|
1248
1259
|
}
|
|
1260
|
+
const eraBeforeInitialize = this.era;
|
|
1249
1261
|
this.era = "legacy";
|
|
1250
1262
|
// The SDK's opening classifier treats an initialize that carries a
|
|
1251
1263
|
// valid modern era claim as MODERN, diverging from this admission rule
|
|
@@ -1270,7 +1282,13 @@ export class StdioSupervisor {
|
|
|
1270
1282
|
this.forwardRequest(message, this.createPendingRequest(message));
|
|
1271
1283
|
}
|
|
1272
1284
|
else {
|
|
1273
|
-
|
|
1285
|
+
// Only an INITIAL initialize owns a releasable prefix: while the era
|
|
1286
|
+
// was unselected the sole request that can have queued is an
|
|
1287
|
+
// era-neutral discover. Once legacy is locked ordinary requests queue
|
|
1288
|
+
// too, and letting them overtake a RE-sent initialize would hand them
|
|
1289
|
+
// to an un-handshaken replacement worker.
|
|
1290
|
+
this.initializePredecessorCount =
|
|
1291
|
+
eraBeforeInitialize === "unselected" ? this.queuedRequests.length : 0;
|
|
1274
1292
|
const existing = this.queuedNotifications.findIndex((entry) => isRequest(entry) && entry.method === "initialize");
|
|
1275
1293
|
if (existing >= 0)
|
|
1276
1294
|
this.queuedNotifications.splice(existing, 1);
|
|
@@ -2009,6 +2027,8 @@ export class StdioSupervisor {
|
|
|
2009
2027
|
}
|
|
2010
2028
|
this.child = child;
|
|
2011
2029
|
this.liveChildren.add(child);
|
|
2030
|
+
if (this.spawnsProcessGroupLeaders)
|
|
2031
|
+
this.processGroupLeaders.add(child);
|
|
2012
2032
|
this.workerReaders.set(child, new JsonRpcFrameReader());
|
|
2013
2033
|
const generation = ++this.workerGeneration;
|
|
2014
2034
|
// "close" fires only once the process has ended AND its stdio streams are
|
|
@@ -2407,6 +2427,10 @@ export class StdioSupervisor {
|
|
|
2407
2427
|
const wasReady = this.childReady;
|
|
2408
2428
|
const readyAt = this.childReadyAt;
|
|
2409
2429
|
this.detachCurrentChild();
|
|
2430
|
+
// The worker ended on its own (crash, OOM, an external kill of the worker
|
|
2431
|
+
// alone): no supervisor-initiated termination ran, so any descendant it
|
|
2432
|
+
// forked is still alive in its process group with no other owner.
|
|
2433
|
+
this.reapExitedWorkerGroup(child, childPid);
|
|
2410
2434
|
if (this.shuttingDown) {
|
|
2411
2435
|
return;
|
|
2412
2436
|
}
|
|
@@ -2925,6 +2949,57 @@ export class StdioSupervisor {
|
|
|
2925
2949
|
child.stdin.removeAllListeners("error");
|
|
2926
2950
|
}
|
|
2927
2951
|
}
|
|
2952
|
+
/**
|
|
2953
|
+
* Signals the process group of a worker that exited UNEXPECTEDLY, so the
|
|
2954
|
+
* descendants it leaves behind (a Java grandchild above all) are not
|
|
2955
|
+
* orphaned.
|
|
2956
|
+
*
|
|
2957
|
+
* PID reuse: this runs from the worker's `exit` event, after the worker
|
|
2958
|
+
* itself was reaped, so its pid is free again. The kernel still refuses to
|
|
2959
|
+
* hand that number out as a new pid or pgid while ANY member of the old
|
|
2960
|
+
* group is alive — so while there is something left to reap, `-pid` names
|
|
2961
|
+
* exactly that group. The residual race (the group already empty AND the
|
|
2962
|
+
* number reused by a new group leader in between) is accepted as negligible.
|
|
2963
|
+
* pidfd-based group signalling would close it but needs Linux 6.9+.
|
|
2964
|
+
*
|
|
2965
|
+
* Fire-and-forget by design: it is not recorded as an in-flight cleanup, so
|
|
2966
|
+
* it never occupies a live-generation slot or delays the replacement.
|
|
2967
|
+
*
|
|
2968
|
+
* Windows: `taskkill /T` walks the tree from its root, and the root has
|
|
2969
|
+
* already exited here, so there is nothing it can find. Descendants of a
|
|
2970
|
+
* crashed worker are not reaped on Windows (known limitation).
|
|
2971
|
+
*
|
|
2972
|
+
* Logged `result`: "signalled" (the group had members and was signalled),
|
|
2973
|
+
* "group-empty" (ESRCH — nothing was left), or "failed".
|
|
2974
|
+
*/
|
|
2975
|
+
reapExitedWorkerGroup(child, pid) {
|
|
2976
|
+
if (pid === undefined)
|
|
2977
|
+
return;
|
|
2978
|
+
const report = (result) => {
|
|
2979
|
+
this.eventWriter(result === "failed" ? "warn" : "info", "supervisor.worker_exit_group_cleanup", {
|
|
2980
|
+
pid,
|
|
2981
|
+
result
|
|
2982
|
+
});
|
|
2983
|
+
};
|
|
2984
|
+
if (process.platform === "win32") {
|
|
2985
|
+
debugSupervisor("worker_exit_group_unreachable", { pid });
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
if (!this.processGroupLeaders.has(child))
|
|
2989
|
+
return;
|
|
2990
|
+
let groupEmpty = false;
|
|
2991
|
+
const signalled = terminatePosixProcessGroup(pid, (target, signal) => {
|
|
2992
|
+
try {
|
|
2993
|
+
process.kill(target, signal);
|
|
2994
|
+
}
|
|
2995
|
+
catch (error) {
|
|
2996
|
+
if (error?.code === "ESRCH")
|
|
2997
|
+
groupEmpty = true;
|
|
2998
|
+
throw error;
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
report(!signalled ? "failed" : groupEmpty ? "group-empty" : "signalled");
|
|
3002
|
+
}
|
|
2928
3003
|
recoverTimedOutWorker() {
|
|
2929
3004
|
const child = this.child;
|
|
2930
3005
|
if (!child) {
|
|
@@ -19,6 +19,13 @@ export interface SearchFilesOptions {
|
|
|
19
19
|
cursor?: string;
|
|
20
20
|
mode?: "mixed" | "text" | "path";
|
|
21
21
|
fetchLimitOverride?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Literal file_path prefix (e.g. `net/minecraft/`) that narrows the candidate
|
|
24
|
+
* queries through an escaped, ASCII case-insensitive `LIKE prefix%`. That match
|
|
25
|
+
* set is a superset of a case-sensitive startsWith, so callers keep their own
|
|
26
|
+
* exact scope check on the returned candidates.
|
|
27
|
+
*/
|
|
28
|
+
pathPrefix?: string;
|
|
22
29
|
}
|
|
23
30
|
export interface SearchFilesResult {
|
|
24
31
|
filePath: string;
|
|
@@ -108,6 +108,15 @@ function buildPreview(content, query) {
|
|
|
108
108
|
function tokenizeIndexedQuery(query) {
|
|
109
109
|
return query.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Quote a token so FTS5 reads it as a string literal instead of grammar. Real
|
|
113
|
+
* Minecraft identifiers tokenize into operator keywords (`BooleanOp.AND` ->
|
|
114
|
+
* `BooleanOp AND`), which unquoted is either a syntax error or - mid-query, as
|
|
115
|
+
* in `a NOT b` - a silent change of meaning.
|
|
116
|
+
*/
|
|
117
|
+
function quoteIndexedToken(token) {
|
|
118
|
+
return `"${token.replace(/"/g, '""')}"`;
|
|
119
|
+
}
|
|
111
120
|
function buildIndexedMatchQuery(query, match) {
|
|
112
121
|
const tokens = tokenizeIndexedQuery(query.trim());
|
|
113
122
|
if (tokens.length === 0) {
|
|
@@ -118,10 +127,11 @@ function buildIndexedMatchQuery(query, match) {
|
|
|
118
127
|
// path while SourceService re-checks hydrated path/content matches before
|
|
119
128
|
// returning hits. Callers can still use literal mode for exact substring scans.
|
|
120
129
|
return tokens.map((token, index) => {
|
|
130
|
+
const quoted = quoteIndexedToken(token);
|
|
121
131
|
if (match === "prefix" && index === tokens.length - 1) {
|
|
122
|
-
return `${
|
|
132
|
+
return `${quoted}*`;
|
|
123
133
|
}
|
|
124
|
-
return
|
|
134
|
+
return quoted;
|
|
125
135
|
}).join(" ");
|
|
126
136
|
}
|
|
127
137
|
/** Escape LIKE wildcards so the needle is matched literally under ESCAPE '\'. */
|
|
@@ -177,6 +187,7 @@ export class FilesRepo {
|
|
|
177
187
|
SELECT file_path
|
|
178
188
|
FROM files
|
|
179
189
|
WHERE artifact_id = ? AND file_path LIKE ? ESCAPE '\\'
|
|
190
|
+
AND (? IS NULL OR file_path LIKE ? ESCAPE '\\')
|
|
180
191
|
ORDER BY file_path ASC
|
|
181
192
|
LIMIT ?
|
|
182
193
|
`);
|
|
@@ -191,6 +202,7 @@ export class FilesRepo {
|
|
|
191
202
|
SELECT file_path, rank
|
|
192
203
|
FROM files_fts
|
|
193
204
|
WHERE artifact_id = ? AND files_fts MATCH ?
|
|
205
|
+
AND (? IS NULL OR file_path LIKE ? ESCAPE '\\')
|
|
194
206
|
ORDER BY rank
|
|
195
207
|
LIMIT ?
|
|
196
208
|
`);
|
|
@@ -314,6 +326,7 @@ export class FilesRepo {
|
|
|
314
326
|
const cursor = parseSearchCursor(options.cursor);
|
|
315
327
|
const likeQuery = `%${escapeLikeNeedle(normalized)}%`;
|
|
316
328
|
const ftsQuery = buildIndexedMatchQuery(normalized, options.match);
|
|
329
|
+
const scopePattern = options.pathPrefix ? `${escapeLikeNeedle(options.pathPrefix)}%` : null;
|
|
317
330
|
const mode = options.mode ?? "mixed";
|
|
318
331
|
// Cursor-adaptive fetch limit: when no cursor, use a generous limit;
|
|
319
332
|
// with cursor + SQL pushdown, we need far fewer rows.
|
|
@@ -325,7 +338,7 @@ export class FilesRepo {
|
|
|
325
338
|
const includePath = mode !== "text" && !cursorExhausted;
|
|
326
339
|
const includeContent = mode !== "path" && !cursorExhausted;
|
|
327
340
|
const pathRows = includePath
|
|
328
|
-
? this.searchPathStmt.all(artifactId, likeQuery, fetchLimit)
|
|
341
|
+
? this.searchPathStmt.all(artifactId, likeQuery, scopePattern, scopePattern, fetchLimit)
|
|
329
342
|
: [];
|
|
330
343
|
const merged = pathRows.map((row) => ({
|
|
331
344
|
filePath: row.file_path,
|
|
@@ -336,7 +349,7 @@ export class FilesRepo {
|
|
|
336
349
|
let contentRows = [];
|
|
337
350
|
if (includeContent && ftsQuery) {
|
|
338
351
|
try {
|
|
339
|
-
contentRows = this.searchFtsStmt.all(artifactId, ftsQuery, fetchLimit);
|
|
352
|
+
contentRows = this.searchFtsStmt.all(artifactId, ftsQuery, scopePattern, scopePattern, fetchLimit);
|
|
340
353
|
}
|
|
341
354
|
catch (error) {
|
|
342
355
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -17,7 +17,7 @@ const SECTION_ROWS = {
|
|
|
17
17
|
"| `compare-minecraft` | Compare version pairs, class diffs, registry diffs, and migration-oriented summaries |",
|
|
18
18
|
"| `analyze-mod` | Summarize mod metadata, decompile and search mod code, inspect class source, read class members from bytecode, and preview or apply remaps |",
|
|
19
19
|
"| `validate-project` | Summarize workspaces and run direct Mixin, Access Widener, or Access Transformer validation |",
|
|
20
|
-
"| `manage-cache` | List, verify, and preview or apply cache cleanup
|
|
20
|
+
"| `manage-cache` | List, verify, and preview or apply cache cleanup operations |"
|
|
21
21
|
],
|
|
22
22
|
ja: [
|
|
23
23
|
"| `inspect-minecraft` | バージョン、アーティファクト、クラス、ファイル、ソース本文、ワークスペース文脈の調査フローをまとめて扱う |",
|
|
@@ -25,7 +25,7 @@ const SECTION_ROWS = {
|
|
|
25
25
|
"| `compare-minecraft` | バージョン差分、クラス差分、レジストリ差分、移行向け概要を比較する |",
|
|
26
26
|
"| `analyze-mod` | Mod メタデータの要約、Mod コードのデコンパイル / 検索、クラスソース確認、リマップのプレビュー / 実行を扱う |",
|
|
27
27
|
"| `validate-project` | ワークスペース要約と、Mixin / Access Widener / Access Transformer の直接検証を行う |",
|
|
28
|
-
"| `manage-cache` |
|
|
28
|
+
"| `manage-cache` | キャッシュの一覧、検証、クリーンアップのプレビュー / 実行を行う |"
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
31
|
"source-exploration": {
|
|
@@ -4,7 +4,8 @@ const DEFAULT_OPTIONS = {
|
|
|
4
4
|
maxQueue: 2
|
|
5
5
|
};
|
|
6
6
|
// Heavy tools that have a batch equivalent able to collapse many same-kind
|
|
7
|
-
// queries into
|
|
7
|
+
// queries into one call. The batch-* tools are NOT in the heavy set, so that
|
|
8
|
+
// one call bypasses this gate entirely. Used to point overflow guidance at the
|
|
8
9
|
// right batch-* tool instead of just telling the caller to retry serially.
|
|
9
10
|
const BATCH_EQUIVALENTS = {
|
|
10
11
|
"find-mapping": "batch-mappings",
|
package/dist/version-service.js
CHANGED
|
@@ -390,6 +390,13 @@ export class VersionService {
|
|
|
390
390
|
try {
|
|
391
391
|
const response = await this.fetchFn(url, { signal: timeout.signal });
|
|
392
392
|
if (!response.ok) {
|
|
393
|
+
// Nothing reads an error body here, and an unread one pins its socket
|
|
394
|
+
// until GC reaches it. Released best-effort, exactly as `releaseBody`
|
|
395
|
+
// in repo-downloader.ts: a body that refuses to be cancelled is not a
|
|
396
|
+
// reason to lose the status the caller actually needs.
|
|
397
|
+
void response.body?.cancel().catch(() => {
|
|
398
|
+
// best-effort release
|
|
399
|
+
});
|
|
393
400
|
throw createError({
|
|
394
401
|
code: ERROR_CODES.REPO_FETCH_FAILED,
|
|
395
402
|
message: `Request failed for "${url}" with status ${response.status}.`,
|
|
@@ -5,6 +5,22 @@ export type WorkspaceContextEvidence = {
|
|
|
5
5
|
field: string;
|
|
6
6
|
value?: string;
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* A stat-based snapshot of one file the detection that produced a
|
|
10
|
+
* `WorkspaceContext` actually read, taken at write time. `read()` re-stats
|
|
11
|
+
* every entry and treats any difference - a changed mtime/size, a file that
|
|
12
|
+
* appeared, or one that disappeared - as a cache miss, so an edit to a
|
|
13
|
+
* project's build files invalidates the cached context immediately instead
|
|
14
|
+
* of waiting out the TTL.
|
|
15
|
+
*/
|
|
16
|
+
export type WorkspaceContextFingerprintEntry = {
|
|
17
|
+
path: string;
|
|
18
|
+
mtimeMs: number;
|
|
19
|
+
size: number;
|
|
20
|
+
} | {
|
|
21
|
+
path: string;
|
|
22
|
+
absent: true;
|
|
23
|
+
};
|
|
8
24
|
export type WorkspaceContext = {
|
|
9
25
|
projectPath: string;
|
|
10
26
|
minecraftVersion?: string;
|
|
@@ -14,6 +30,7 @@ export type WorkspaceContext = {
|
|
|
14
30
|
evidence: WorkspaceContextEvidence[];
|
|
15
31
|
dependencyVersions: Map<string, string>;
|
|
16
32
|
partial?: boolean;
|
|
33
|
+
fingerprint?: WorkspaceContextFingerprintEntry[];
|
|
17
34
|
};
|
|
18
35
|
export interface WorkspaceContextCache {
|
|
19
36
|
read(projectPath: string): WorkspaceContext | undefined;
|
|
@@ -27,6 +44,14 @@ export type WorkspaceContextCacheOptions = {
|
|
|
27
44
|
ttlMs?: number;
|
|
28
45
|
clock?: () => number;
|
|
29
46
|
};
|
|
47
|
+
/**
|
|
48
|
+
* Stat-snapshot the given file paths (deduplicated, order-independent) for a
|
|
49
|
+
* `WorkspaceContext.fingerprint`. Exported so the workspace-context populator
|
|
50
|
+
* (the only place that knows which files a detection actually read) can build
|
|
51
|
+
* the same shape `read()` compares against, without duplicating the stat
|
|
52
|
+
* logic or its absent-file handling.
|
|
53
|
+
*/
|
|
54
|
+
export declare function computeWorkspaceContextFingerprint(paths: readonly string[]): WorkspaceContextFingerprintEntry[];
|
|
30
55
|
export declare function createWorkspaceContextCache(opts?: WorkspaceContextCacheOptions): WorkspaceContextCache;
|
|
31
56
|
export declare function getProcessWorkspaceContextCache(): WorkspaceContextCache;
|
|
32
57
|
export declare function resetProcessWorkspaceContextCacheForTesting(): void;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
1
2
|
import { resolve as resolvePath } from "node:path";
|
|
2
3
|
import { LruList } from "./lru-list.js";
|
|
3
4
|
const DEFAULT_MAX_ENTRIES = 16;
|
|
@@ -5,6 +6,55 @@ const DEFAULT_TTL_MS = 5 * 60_000;
|
|
|
5
6
|
function normalizeKey(projectPath) {
|
|
6
7
|
return resolvePath(projectPath);
|
|
7
8
|
}
|
|
9
|
+
function currentFingerprintEntry(path) {
|
|
10
|
+
try {
|
|
11
|
+
const stats = statSync(path);
|
|
12
|
+
return { path, mtimeMs: stats.mtimeMs, size: stats.size };
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Missing, unreadable, or a broken symlink - all the same "not there"
|
|
16
|
+
// state as far as invalidation cares about.
|
|
17
|
+
return { path, absent: true };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Stat-snapshot the given file paths (deduplicated, order-independent) for a
|
|
22
|
+
* `WorkspaceContext.fingerprint`. Exported so the workspace-context populator
|
|
23
|
+
* (the only place that knows which files a detection actually read) can build
|
|
24
|
+
* the same shape `read()` compares against, without duplicating the stat
|
|
25
|
+
* logic or its absent-file handling.
|
|
26
|
+
*/
|
|
27
|
+
export function computeWorkspaceContextFingerprint(paths) {
|
|
28
|
+
const unique = Array.from(new Set(paths.map((path) => resolvePath(path))));
|
|
29
|
+
return unique.map((path) => currentFingerprintEntry(path));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Whether any file recorded in `ctx.fingerprint` no longer matches its
|
|
33
|
+
* recorded size/mtime (or its recorded/current absence disagrees). A context
|
|
34
|
+
* with no fingerprint (nothing was recorded, e.g. a dependency-only partial
|
|
35
|
+
* entry) never invalidates on this check - only the TTL applies to it.
|
|
36
|
+
*/
|
|
37
|
+
function fingerprintStale(ctx) {
|
|
38
|
+
if (!ctx.fingerprint || ctx.fingerprint.length === 0) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
for (const recorded of ctx.fingerprint) {
|
|
42
|
+
const current = currentFingerprintEntry(recorded.path);
|
|
43
|
+
const recordedAbsent = "absent" in recorded && recorded.absent === true;
|
|
44
|
+
const currentAbsent = "absent" in current && current.absent === true;
|
|
45
|
+
if (recordedAbsent !== currentAbsent) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (!recordedAbsent && !currentAbsent) {
|
|
49
|
+
const recordedStat = recorded;
|
|
50
|
+
const currentStat = current;
|
|
51
|
+
if (recordedStat.mtimeMs !== currentStat.mtimeMs || recordedStat.size !== currentStat.size) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
8
58
|
export function createWorkspaceContextCache(opts = {}) {
|
|
9
59
|
const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
10
60
|
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
@@ -29,7 +79,7 @@ export function createWorkspaceContextCache(opts = {}) {
|
|
|
29
79
|
if (!value) {
|
|
30
80
|
return undefined;
|
|
31
81
|
}
|
|
32
|
-
if (isExpired(value)) {
|
|
82
|
+
if (isExpired(value) || fingerprintStale(value)) {
|
|
33
83
|
lru.remove(key);
|
|
34
84
|
return undefined;
|
|
35
85
|
}
|
|
@@ -46,7 +96,7 @@ export function createWorkspaceContextCache(opts = {}) {
|
|
|
46
96
|
},
|
|
47
97
|
list() {
|
|
48
98
|
const all = lru.toArray().map((entry) => entry.value);
|
|
49
|
-
return all.filter((value) => !isExpired(value));
|
|
99
|
+
return all.filter((value) => !isExpired(value) && !fingerprintStale(value));
|
|
50
100
|
},
|
|
51
101
|
clear() {
|
|
52
102
|
lru.clear();
|
|
@@ -1,13 +1,121 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { access, readdir, readFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { resolve } from "node:path";
|
|
5
4
|
import fastGlob from "fast-glob";
|
|
6
5
|
import { mapWithConcurrencyLimit } from "./concurrency.js";
|
|
7
6
|
import { createError, ERROR_CODES } from "./errors.js";
|
|
7
|
+
import { resolveGradleUserHomePath } from "./gradle-paths.js";
|
|
8
8
|
import { isSafeMavenSegment, isSafeMavenVersionToken } from "./maven-token.js";
|
|
9
9
|
const WORKSPACE_FILE_READ_CONCURRENCY = 4;
|
|
10
|
-
function
|
|
10
|
+
function gradleScriptKind(filePath) {
|
|
11
|
+
return filePath.endsWith(".kts") ? "kotlin" : "groovy";
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Blank out Gradle Groovy/Kotlin line and block comments so a commented-out
|
|
15
|
+
* alternative (`// mappings loom.officialMojangMappings()`) cannot count as a
|
|
16
|
+
* mapping declaration — a Yarn project that kept such a note used to look like it
|
|
17
|
+
* declared two mappings and resolved to none.
|
|
18
|
+
*
|
|
19
|
+
* Literals are tracked rather than skipped over, because a `//` inside
|
|
20
|
+
* `"https://..."` opens no comment and a `/*` inside a string must not swallow the
|
|
21
|
+
* rest of the script. Newlines inside a removed block comment are preserved so the
|
|
22
|
+
* remaining declarations keep their line structure.
|
|
23
|
+
*
|
|
24
|
+
* The two dialects differ in three places, which is why `scriptKind` is threaded in
|
|
25
|
+
* from the file name rather than guessed from the text: Kotlin block comments NEST
|
|
26
|
+
* (Groovy's do not), a Kotlin raw string `"""..."""` has NO escape sequences (so
|
|
27
|
+
* honoring `\` there would let a literal ending in a backslash hide its own
|
|
28
|
+
* terminator), and the dollar-slashy literal `$/ ... /$` is Groovy-only.
|
|
29
|
+
*
|
|
30
|
+
* When a construct is ambiguous the scanner keeps the text instead of dropping it: a
|
|
31
|
+
* false mapping conflict is a smaller failure than a declaration that never reaches
|
|
32
|
+
* the detectors.
|
|
33
|
+
*/
|
|
34
|
+
function stripGradleComments(content, scriptKind) {
|
|
35
|
+
let output = "";
|
|
36
|
+
let index = 0;
|
|
37
|
+
while (index < content.length) {
|
|
38
|
+
const char = content[index];
|
|
39
|
+
if (char === "/" && content[index + 1] === "/") {
|
|
40
|
+
while (index < content.length && content[index] !== "\n") {
|
|
41
|
+
index += 1;
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (char === "/" && content[index + 1] === "*") {
|
|
46
|
+
let depth = 1;
|
|
47
|
+
index += 2;
|
|
48
|
+
while (index < content.length && depth > 0) {
|
|
49
|
+
if (scriptKind === "kotlin" && content[index] === "/" && content[index + 1] === "*") {
|
|
50
|
+
depth += 1;
|
|
51
|
+
index += 2;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (content[index] === "*" && content[index + 1] === "/") {
|
|
55
|
+
depth -= 1;
|
|
56
|
+
index += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (content[index] === "\n") {
|
|
60
|
+
output += "\n";
|
|
61
|
+
}
|
|
62
|
+
index += 1;
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
// Groovy dollar-slashy literal: `$/ ... /$`, whose only escapes are `$$` and `$/`.
|
|
67
|
+
// The plain slashy form `/.../` is deliberately NOT recognized — `/` is also the
|
|
68
|
+
// division operator, so `a / b / c` is indistinguishable from a literal without
|
|
69
|
+
// parsing expressions, and mistaking code for a literal would hide real comments.
|
|
70
|
+
if (scriptKind === "groovy" && char === "$" && content[index + 1] === "/") {
|
|
71
|
+
output += "$/";
|
|
72
|
+
index += 2;
|
|
73
|
+
while (index < content.length) {
|
|
74
|
+
if (content[index] === "$" && (content[index + 1] === "$" || content[index + 1] === "/")) {
|
|
75
|
+
output += content.slice(index, index + 2);
|
|
76
|
+
index += 2;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (content[index] === "/" && content[index + 1] === "$") {
|
|
80
|
+
output += "/$";
|
|
81
|
+
index += 2;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
output += content[index];
|
|
85
|
+
index += 1;
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (char === '"' || char === "'") {
|
|
90
|
+
// Triple-quoted Groovy/Kotlin blocks close only on the matching triple.
|
|
91
|
+
const tripled = content.startsWith(char.repeat(3), index);
|
|
92
|
+
const quote = tripled ? char.repeat(3) : char;
|
|
93
|
+
const honorsEscapes = !(tripled && scriptKind === "kotlin");
|
|
94
|
+
output += quote;
|
|
95
|
+
index += quote.length;
|
|
96
|
+
while (index < content.length) {
|
|
97
|
+
if (honorsEscapes && content[index] === "\\") {
|
|
98
|
+
output += content.slice(index, index + 2);
|
|
99
|
+
index += 2;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (content.startsWith(quote, index)) {
|
|
103
|
+
output += quote;
|
|
104
|
+
index += quote.length;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
output += content[index];
|
|
108
|
+
index += 1;
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
output += char;
|
|
113
|
+
index += 1;
|
|
114
|
+
}
|
|
115
|
+
return output;
|
|
116
|
+
}
|
|
117
|
+
function detectMappingsFromContent(rawContent, scriptKind) {
|
|
118
|
+
const content = stripGradleComments(rawContent, scriptKind);
|
|
11
119
|
const detections = [];
|
|
12
120
|
if (/officialMojangMappings\s*\(/i.test(content)) {
|
|
13
121
|
detections.push({
|
|
@@ -149,7 +257,7 @@ async function adoptSubmoduleVersionFromUmbrellaPom(args) {
|
|
|
149
257
|
if (!umbrellaVersion) {
|
|
150
258
|
return undefined;
|
|
151
259
|
}
|
|
152
|
-
const umbrellaDir = resolve(
|
|
260
|
+
const umbrellaDir = resolve(resolveGradleUserHomePath(), "caches", "modules-2", "files-2.1", group, groupSegment, umbrellaVersion);
|
|
153
261
|
attempts.push(`umbrella-pom:${umbrellaDir}`);
|
|
154
262
|
let hashDirs = [];
|
|
155
263
|
try {
|
|
@@ -225,14 +333,8 @@ function compareSemverDescending(left, right) {
|
|
|
225
333
|
* working; there is still exactly one definition.
|
|
226
334
|
*/
|
|
227
335
|
export { isSafeMavenVersionToken };
|
|
228
|
-
function
|
|
229
|
-
const
|
|
230
|
-
if (configured) {
|
|
231
|
-
return configured;
|
|
232
|
-
}
|
|
233
|
-
return resolve(homedir(), ".gradle");
|
|
234
|
-
}
|
|
235
|
-
function detectLoadersFromContent(content) {
|
|
336
|
+
function detectLoadersFromContent(rawContent, scriptKind) {
|
|
337
|
+
const content = stripGradleComments(rawContent, scriptKind);
|
|
236
338
|
const detections = [];
|
|
237
339
|
if (/\bid\s*(?:\(\s*)?["']net\.neoforged\.moddev["']\s*\)?/i.test(content)) {
|
|
238
340
|
detections.push({
|
|
@@ -303,7 +405,7 @@ export class WorkspaceMappingService {
|
|
|
303
405
|
catch {
|
|
304
406
|
return [];
|
|
305
407
|
}
|
|
306
|
-
return detectMappingsFromContent(content).map((detection) => ({
|
|
408
|
+
return detectMappingsFromContent(content, gradleScriptKind(filePath)).map((detection) => ({
|
|
307
409
|
filePath,
|
|
308
410
|
mapping: detection.mapping,
|
|
309
411
|
reason: detection.reason
|
|
@@ -439,7 +541,7 @@ export class WorkspaceMappingService {
|
|
|
439
541
|
attempts.push(`gradle.properties:${key}`);
|
|
440
542
|
}
|
|
441
543
|
}
|
|
442
|
-
const modulesDir = resolve(
|
|
544
|
+
const modulesDir = resolve(resolveGradleUserHomePath(), "caches", "modules-2", "files-2.1", group, name);
|
|
443
545
|
attempts.push(`modules-2:${modulesDir}`);
|
|
444
546
|
let entries = [];
|
|
445
547
|
try {
|
|
@@ -520,7 +622,7 @@ export class WorkspaceMappingService {
|
|
|
520
622
|
catch {
|
|
521
623
|
return [];
|
|
522
624
|
}
|
|
523
|
-
return detectLoadersFromContent(content).map((detection) => ({
|
|
625
|
+
return detectLoadersFromContent(content, gradleScriptKind(filePath)).map((detection) => ({
|
|
524
626
|
filePath,
|
|
525
627
|
loader: detection.loader,
|
|
526
628
|
reason: detection.reason
|
package/docs/README-ja.md
CHANGED
|
@@ -264,7 +264,7 @@ stdio トランスポートは、改行区切り形式と `Content-Length` フ
|
|
|
264
264
|
| `compare-minecraft` | バージョン差分、クラス差分、レジストリ差分、移行向け概要を比較する |
|
|
265
265
|
| `analyze-mod` | Mod メタデータの要約、Mod コードのデコンパイル / 検索、クラスソース確認、リマップのプレビュー / 実行を扱う |
|
|
266
266
|
| `validate-project` | ワークスペース要約と、Mixin / Access Widener / Access Transformer の直接検証を行う |
|
|
267
|
-
| `manage-cache` |
|
|
267
|
+
| `manage-cache` | キャッシュの一覧、検証、クリーンアップのプレビュー / 実行を行う |
|
|
268
268
|
<!-- END GENERATED TOOL TABLE: top-level-workflow-tools -->
|
|
269
269
|
|
|
270
270
|
### ソース探索
|