@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +1 -1
  3. package/dist/access-transformer-parser.d.ts +8 -0
  4. package/dist/access-transformer-parser.js +8 -1
  5. package/dist/access-widener-parser.d.ts +17 -0
  6. package/dist/access-widener-parser.js +12 -1
  7. package/dist/cache-registry.js +19 -6
  8. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +15 -9
  9. package/dist/entry-tools/manage-cache-service.js +10 -14
  10. package/dist/entry-tools/validate-project/cases/access-transformer.js +22 -3
  11. package/dist/entry-tools/validate-project/cases/access-widener.js +31 -4
  12. package/dist/entry-tools/validate-project/cases/mixin.js +11 -3
  13. package/dist/entry-tools/validate-project/cases/project-summary.js +24 -5
  14. package/dist/entry-tools/verify-mixin-target-service.js +19 -8
  15. package/dist/index.js +1 -0
  16. package/dist/java-process.d.ts +1 -0
  17. package/dist/java-process.js +14 -0
  18. package/dist/mapping/lookup.js +16 -1
  19. package/dist/mapping-service.d.ts +14 -0
  20. package/dist/mapping-service.js +35 -15
  21. package/dist/minecraft-explorer-service.js +70 -8
  22. package/dist/mixin/access-validators.js +38 -2
  23. package/dist/mixin/annotation-validators.js +137 -43
  24. package/dist/mixin/parsed-validator.js +21 -7
  25. package/dist/mixin-parser.d.ts +52 -0
  26. package/dist/mixin-parser.js +709 -130
  27. package/dist/mod-decompile-service.js +11 -1
  28. package/dist/mod-remap-service.js +6 -6
  29. package/dist/nbt/java-nbt-codec.js +7 -1
  30. package/dist/source/access-validate.js +10 -0
  31. package/dist/source/artifact-resolver.d.ts +13 -3
  32. package/dist/source/artifact-resolver.js +129 -18
  33. package/dist/source/class-source.js +13 -2
  34. package/dist/source/nested-jars.d.ts +15 -1
  35. package/dist/source/nested-jars.js +14 -5
  36. package/dist/source/search.d.ts +10 -2
  37. package/dist/source/search.js +60 -13
  38. package/dist/source/validate-mixin/pipeline/mapping-health.js +20 -1
  39. package/dist/source/validate-mixin/pipeline/target-lookup.js +16 -7
  40. package/dist/source/validate-mixin.d.ts +5 -0
  41. package/dist/source/validate-mixin.js +136 -21
  42. package/dist/source/workspace-target.js +75 -7
  43. package/dist/source-jar-reader.d.ts +15 -1
  44. package/dist/source-jar-reader.js +35 -3
  45. package/dist/source-resolver.js +2 -9
  46. package/dist/stdio-supervisor.d.ts +35 -1
  47. package/dist/stdio-supervisor.js +77 -2
  48. package/dist/storage/files-repo.d.ts +7 -0
  49. package/dist/storage/files-repo.js +17 -4
  50. package/dist/tool-contract-manifest.js +2 -2
  51. package/dist/tool-execution-gate.js +2 -1
  52. package/dist/version-service.js +7 -0
  53. package/dist/workspace-context-cache.d.ts +25 -0
  54. package/dist/workspace-context-cache.js +52 -2
  55. package/dist/workspace-mapping-service.js +116 -14
  56. package/docs/README-ja.md +1 -1
  57. package/docs/tool-reference.md +14 -11
  58. package/package.json +1 -1
@@ -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
- this.initializePredecessorCount = this.queuedRequests.length;
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 `${token}*`;
132
+ return `${quoted}*`;
123
133
  }
124
- return token;
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 and rebuild operations |"
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 a single gated call. Used to point overflow guidance at the
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",
@@ -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 detectMappingsFromContent(content) {
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(resolveGradleUserHome(), "caches", "modules-2", "files-2.1", group, groupSegment, umbrellaVersion);
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 resolveGradleUserHome() {
229
- const configured = process.env.GRADLE_USER_HOME?.trim();
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(resolveGradleUserHome(), "caches", "modules-2", "files-2.1", group, name);
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
  ### ソース探索