@adhisang/minecraft-modding-mcp 4.2.1 → 6.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.
Files changed (144) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +5 -4
  3. package/dist/access-transformer-parser.d.ts +2 -1
  4. package/dist/access-transformer-parser.js +10 -1
  5. package/dist/access-widener-parser.d.ts +1 -0
  6. package/dist/access-widener-parser.js +6 -3
  7. package/dist/build-suggested-call.js +31 -10
  8. package/dist/cache-registry.d.ts +8 -0
  9. package/dist/cache-registry.js +50 -32
  10. package/dist/config.d.ts +1 -0
  11. package/dist/config.js +4 -0
  12. package/dist/decompiler/vineflower.js +8 -2
  13. package/dist/entry-tools/analyze-mod-service.d.ts +24 -24
  14. package/dist/entry-tools/analyze-mod-service.js +2 -1
  15. package/dist/entry-tools/analyze-symbol-service.d.ts +57 -22
  16. package/dist/entry-tools/analyze-symbol-service.js +139 -34
  17. package/dist/entry-tools/batch-class-members-service.d.ts +3 -1
  18. package/dist/entry-tools/batch-class-members-service.js +6 -6
  19. package/dist/entry-tools/batch-class-source-service.d.ts +3 -1
  20. package/dist/entry-tools/batch-class-source-service.js +5 -6
  21. package/dist/entry-tools/batch-mappings-service.d.ts +3 -1
  22. package/dist/entry-tools/batch-mappings-service.js +4 -5
  23. package/dist/entry-tools/batch-symbol-exists-service.d.ts +3 -1
  24. package/dist/entry-tools/batch-symbol-exists-service.js +4 -5
  25. package/dist/entry-tools/compare-minecraft-service.d.ts +10 -10
  26. package/dist/entry-tools/compare-minecraft-service.js +1 -1
  27. package/dist/entry-tools/entry-tool-schema.d.ts +1 -1
  28. package/dist/entry-tools/inspect-minecraft/handlers/artifact.js +1 -3
  29. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +4 -3
  30. package/dist/entry-tools/inspect-minecraft/handlers/class-overview.js +0 -5
  31. package/dist/entry-tools/inspect-minecraft/handlers/class-source.js +0 -1
  32. package/dist/entry-tools/inspect-minecraft/handlers/file.js +26 -2
  33. package/dist/entry-tools/inspect-minecraft/handlers/list-files.js +0 -1
  34. package/dist/entry-tools/inspect-minecraft/handlers/search.d.ts +8 -0
  35. package/dist/entry-tools/inspect-minecraft/handlers/search.js +13 -2
  36. package/dist/entry-tools/inspect-minecraft/handlers/versions.d.ts +1 -1
  37. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +3 -2
  38. package/dist/entry-tools/inspect-minecraft/internal.d.ts +90 -89
  39. package/dist/entry-tools/inspect-minecraft/internal.js +32 -14
  40. package/dist/entry-tools/inspect-minecraft-service.d.ts +218 -218
  41. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  42. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  43. package/dist/entry-tools/response-contract.d.ts +3 -1
  44. package/dist/entry-tools/response-contract.js +3 -2
  45. package/dist/entry-tools/validate-project/cases/mixin.js +2 -1
  46. package/dist/entry-tools/validate-project/cases/project-summary.js +5 -3
  47. package/dist/entry-tools/validate-project-service.d.ts +42 -36
  48. package/dist/entry-tools/validate-project-service.js +3 -1
  49. package/dist/entry-tools/verify-mixin-target-service.d.ts +30 -0
  50. package/dist/entry-tools/verify-mixin-target-service.js +120 -29
  51. package/dist/error-mapping.d.ts +51 -0
  52. package/dist/error-mapping.js +157 -2
  53. package/dist/index.js +147 -76
  54. package/dist/java-process.js +1 -1
  55. package/dist/json-rpc-framing.d.ts +4 -0
  56. package/dist/json-rpc-framing.js +23 -1
  57. package/dist/mapping/internal-types.d.ts +24 -0
  58. package/dist/mapping/loaders/tiny-maven.js +46 -20
  59. package/dist/mapping/lookup.d.ts +13 -2
  60. package/dist/mapping/lookup.js +60 -2
  61. package/dist/mapping/types.d.ts +6 -0
  62. package/dist/mapping-service.d.ts +26 -2
  63. package/dist/mapping-service.js +212 -55
  64. package/dist/mcp-helpers.d.ts +1 -0
  65. package/dist/mcp-helpers.js +25 -3
  66. package/dist/minecraft-explorer-service.js +24 -11
  67. package/dist/mixin/access-validators.js +33 -1
  68. package/dist/mixin/annotation-validators.js +73 -9
  69. package/dist/mixin/parsed-validator.js +3 -2
  70. package/dist/mixin-parser.js +119 -7
  71. package/dist/mod-decompile-service.d.ts +2 -0
  72. package/dist/mod-decompile-service.js +21 -3
  73. package/dist/mod-remap-service.d.ts +20 -1
  74. package/dist/mod-remap-service.js +101 -45
  75. package/dist/mojang-tiny-mapping-service.js +5 -2
  76. package/dist/nbt/java-nbt-codec.js +46 -16
  77. package/dist/nbt/json-patch.js +51 -12
  78. package/dist/nbt/typed-json.d.ts +1 -0
  79. package/dist/nbt/typed-json.js +12 -2
  80. package/dist/nbt/types.d.ts +2 -2
  81. package/dist/observability.d.ts +6 -0
  82. package/dist/observability.js +10 -0
  83. package/dist/page-cursor.d.ts +17 -0
  84. package/dist/page-cursor.js +0 -0
  85. package/dist/path-converter.js +10 -3
  86. package/dist/registry-service.d.ts +2 -0
  87. package/dist/registry-service.js +41 -2
  88. package/dist/repo-downloader.js +4 -3
  89. package/dist/resources.js +45 -9
  90. package/dist/response-utils.d.ts +29 -4
  91. package/dist/response-utils.js +95 -5
  92. package/dist/source/access-validate.js +12 -2
  93. package/dist/source/artifact-resolver.js +2 -2
  94. package/dist/source/class-source/members-builder.d.ts +44 -1
  95. package/dist/source/class-source/members-builder.js +51 -4
  96. package/dist/source/class-source/snippet-builder.d.ts +13 -0
  97. package/dist/source/class-source/snippet-builder.js +54 -4
  98. package/dist/source/class-source-helpers.d.ts +4 -4
  99. package/dist/source/class-source-helpers.js +77 -20
  100. package/dist/source/class-source.d.ts +19 -0
  101. package/dist/source/class-source.js +144 -40
  102. package/dist/source/file-access.js +3 -2
  103. package/dist/source/indexer.js +29 -9
  104. package/dist/source/lifecycle/mapping-helpers.js +28 -3
  105. package/dist/source/lifecycle/runtime-check.js +20 -6
  106. package/dist/source/search.d.ts +1 -1
  107. package/dist/source/search.js +117 -30
  108. package/dist/source/validate-mixin/pipeline/resolve.js +23 -3
  109. package/dist/source/validate-mixin.d.ts +2 -1
  110. package/dist/source/validate-mixin.js +45 -4
  111. package/dist/source/workspace-target.js +17 -10
  112. package/dist/source-jar-reader.d.ts +54 -1
  113. package/dist/source-jar-reader.js +121 -10
  114. package/dist/source-resolver.js +2 -2
  115. package/dist/source-service.d.ts +28 -7
  116. package/dist/source-service.js +9 -1
  117. package/dist/stdio-supervisor.d.ts +2 -2
  118. package/dist/stdio-supervisor.js +29 -9
  119. package/dist/storage/db.js +24 -1
  120. package/dist/storage/files-repo.d.ts +15 -0
  121. package/dist/storage/files-repo.js +67 -37
  122. package/dist/storage/symbols-repo.d.ts +15 -0
  123. package/dist/storage/symbols-repo.js +53 -19
  124. package/dist/symbols/symbol-extractor.js +35 -6
  125. package/dist/text-truncate.d.ts +7 -0
  126. package/dist/text-truncate.js +21 -0
  127. package/dist/tiny-remapper-resolver.js +10 -0
  128. package/dist/tool-contract-manifest.js +2 -2
  129. package/dist/tool-execution-gate.js +12 -1
  130. package/dist/tool-guidance.d.ts +6 -2
  131. package/dist/tool-guidance.js +63 -13
  132. package/dist/tool-schemas.d.ts +561 -496
  133. package/dist/tool-schemas.js +175 -139
  134. package/dist/types.d.ts +1 -0
  135. package/dist/version-diff-service.js +5 -0
  136. package/dist/version-service.d.ts +3 -0
  137. package/dist/version-service.js +18 -2
  138. package/dist/warning-details.d.ts +30 -0
  139. package/dist/warning-details.js +80 -0
  140. package/dist/workspace-mapping-service.js +9 -0
  141. package/docs/README-ja.md +2 -2
  142. package/docs/examples.md +26 -2
  143. package/docs/tool-reference.md +22 -17
  144. package/package.json +1 -1
@@ -183,6 +183,17 @@ export function validateParsedAccessTransformer(parsed, membersByClass, warnings
183
183
  invalidCount++;
184
184
  continue;
185
185
  }
186
+ if (entry.targetKind === "wildcard-all" || entry.targetKind === "wildcard-method") {
187
+ // A wildcard transforms all members (or all methods); it is valid as long as
188
+ // the owner class resolved, which it has by this point.
189
+ validatedEntries.push({
190
+ ...entry,
191
+ valid: true,
192
+ ...(options?.includeRuntimeEvidence ? { resolvedInRuntime: true } : {})
193
+ });
194
+ validCount++;
195
+ continue;
196
+ }
186
197
  if (entry.targetKind === "field") {
187
198
  const fieldNames = allFieldNames(members);
188
199
  const matchedField = members.fields.find((member) => member.name === entry.name);
@@ -215,8 +226,29 @@ export function validateParsedAccessTransformer(parsed, membersByClass, warnings
215
226
  continue;
216
227
  }
217
228
  const methodNames = allMethodNames(members);
218
- const matchedMethod = members.methods.find((member) => member.name === entry.name && member.jvmDescriptor === entry.descriptor) ?? members.constructors.find((member) => member.name === entry.name && member.jvmDescriptor === entry.descriptor);
229
+ const sameNameMethods = [...members.methods, ...members.constructors].filter((member) => member.name === entry.name);
230
+ // Match by descriptor when one is supplied (AT method lines always carry one);
231
+ // fall back to a unique name match when it is absent.
232
+ const matchedMethod = entry.descriptor
233
+ ? sameNameMethods.find((member) => member.jvmDescriptor === entry.descriptor)
234
+ : sameNameMethods.length === 1
235
+ ? sameNameMethods[0]
236
+ : undefined;
219
237
  if (!matchedMethod) {
238
+ // Distinguish a true missing method from a descriptor mismatch so the user
239
+ // can see which overloads exist instead of a flat "not found".
240
+ if (entry.descriptor && sameNameMethods.length > 0) {
241
+ const available = [...new Set(sameNameMethods.map((member) => member.jvmDescriptor))];
242
+ validatedEntries.push({
243
+ ...entry,
244
+ valid: false,
245
+ issue: `Method "${entry.name}" exists in "${ownerFqn}" but no overload matches descriptor ${entry.descriptor} (available: ${available.join(", ")}).`,
246
+ suggestions: available,
247
+ ...(options?.includeRuntimeEvidence ? { resolvedInRuntime: false } : {})
248
+ });
249
+ invalidCount++;
250
+ continue;
251
+ }
220
252
  const suggestions = entry.name ? suggestSimilar(entry.name, methodNames) : [];
221
253
  validatedEntries.push({
222
254
  ...entry,
@@ -6,8 +6,26 @@ export function validateInjection(inj, targetMembers, targetNames, issues, resol
6
6
  continue;
7
7
  const methodNames = allMethodNames(members);
8
8
  // Strip owner prefix and JVM descriptor from the method reference
9
- const methodName = extractMethodName(inj.method);
10
- if (!methodNames.includes(methodName)) {
9
+ const rawMethodName = extractMethodName(inj.method);
10
+ // Strip a trailing target-selector quantifier (e.g. "tick*", "tick+", "tick{2,3}")
11
+ const quantifier = /(?:\*|\+|\{\d+(?:,\d+)?\})$/.exec(rawMethodName)?.[0];
12
+ const methodName = quantifier
13
+ ? rawMethodName.slice(0, rawMethodName.length - quantifier.length)
14
+ : rawMethodName;
15
+ if (quantifier && methodName === "") {
16
+ // Bare wildcard selector (e.g. method = "*") matches everything; unverifiable.
17
+ resolvedMembers.push({
18
+ annotation: `@${inj.annotation}`,
19
+ name: rawMethodName,
20
+ line: inj.line,
21
+ status: "skipped"
22
+ });
23
+ continue;
24
+ }
25
+ const matched = (quantifier === "*" || quantifier === "+")
26
+ ? methodNames.some((n) => n.startsWith(methodName))
27
+ : methodNames.includes(methodName);
28
+ if (!matched) {
11
29
  const suggestions = suggestSimilar(methodName, methodNames);
12
30
  const descriptor = extractMethodDescriptor(inj.method);
13
31
  const descriptorHint = descriptor ? ` (descriptor: ${descriptor})` : "";
@@ -43,13 +61,59 @@ export function validateInjection(inj, targetMembers, targetNames, issues, resol
43
61
  });
44
62
  }
45
63
  else {
46
- resolvedMembers.push({
47
- annotation: `@${inj.annotation}`,
48
- name: methodName,
49
- line: inj.line,
50
- resolvedTo: `${targetName}#${methodName}`,
51
- status: "resolved"
52
- });
64
+ // Name matched. If the reference carries a JVM descriptor, require an
65
+ // overload to match it; otherwise a wrong-signature target (e.g. tick(D)V
66
+ // when only tick(I)V exists) is silently reported as resolved.
67
+ const refDescriptor = quantifier ? undefined : extractMethodDescriptor(inj.method);
68
+ const sameNameMembers = [...members.constructors, ...members.methods].filter((member) => member.name === methodName);
69
+ if (refDescriptor &&
70
+ sameNameMembers.length > 0 &&
71
+ !sameNameMembers.some((member) => member.jvmDescriptor === refDescriptor)) {
72
+ const available = [...new Set(sameNameMembers.map((member) => member.jvmDescriptor))];
73
+ const isRemapFailed = remapFailedMembers?.get(targetName)?.has(methodName);
74
+ const isSigFailed = signatureFailedTargets?.has(targetName);
75
+ // Only a healthy mapping lets us trust that the user-written descriptor is
76
+ // byte-equal to the runtime descriptor; otherwise downgrade to a warning.
77
+ const mappingDegraded = Boolean(isRemapFailed) || Boolean(isSigFailed) || healthReport?.overallHealthy === false;
78
+ const resolutionPath = isRemapFailed
79
+ ? "member-remap-failed"
80
+ : isSigFailed
81
+ ? "source-signature-unavailable"
82
+ : undefined;
83
+ const issueConfidence = mappingDegraded
84
+ ? "uncertain"
85
+ : confidence ?? "definite";
86
+ issues.push({
87
+ severity: mappingDegraded ? "warning" : "error",
88
+ kind: "descriptor-mismatch",
89
+ annotation: `@${inj.annotation}`,
90
+ target: `${targetName}#${inj.method}`,
91
+ message: `Method "${methodName}" exists in "${targetName}" but no overload matches descriptor ${refDescriptor} (available: ${available.join(", ")}).${mappingDegraded ? " (mapping degraded; may be a remap artifact)" : ""}`,
92
+ suggestions: available.length > 0 ? available : undefined,
93
+ line: inj.line,
94
+ confidence: issueConfidence,
95
+ confidenceReason: mappingDegraded
96
+ ? `Member remap/signature degraded; descriptor mismatch may be a tooling artifact, not a true signature error.`
97
+ : confidenceReason,
98
+ resolutionPath,
99
+ falsePositiveRisk: computeFalsePositiveRisk(healthReport, resolutionPath, issueConfidence)
100
+ });
101
+ resolvedMembers.push({
102
+ annotation: `@${inj.annotation}`,
103
+ name: methodName,
104
+ line: inj.line,
105
+ status: "not-found"
106
+ });
107
+ }
108
+ else {
109
+ resolvedMembers.push({
110
+ annotation: `@${inj.annotation}`,
111
+ name: methodName,
112
+ line: inj.line,
113
+ resolvedTo: `${targetName}#${methodName}`,
114
+ status: "resolved"
115
+ });
116
+ }
53
117
  }
54
118
  }
55
119
  }
@@ -27,6 +27,7 @@ export function validateParsedMixin(parsed, targetMembers, warnings, provenance,
27
27
  resolutionPath: "target-mapping-failed",
28
28
  falsePositiveRisk: healthReport?.overallHealthy === false ? "high" : "medium"
29
29
  });
30
+ addSkippedMembers(parsed, resolvedMembers);
30
31
  }
31
32
  else if (symbolExistsButSignatureFailed?.has(target.className)) {
32
33
  // Symbol exists in mapping graph but getSignature failed — tool limitation, not code issue
@@ -209,7 +210,7 @@ export function validateParsedMixin(parsed, targetMembers, warnings, provenance,
209
210
  if (version) {
210
211
  assignSuggested(issue, "get-class-source", {
211
212
  className: issue.target,
212
- target: { type: "resolve", kind: "version", value: version },
213
+ target: { kind: "version", value: version },
213
214
  ...(mapping ? { mapping } : {}),
214
215
  mode: "metadata",
215
216
  ...classSourceContext
@@ -236,7 +237,7 @@ export function validateParsedMixin(parsed, targetMembers, warnings, provenance,
236
237
  if (version) {
237
238
  assignSuggested(issue, "get-class-source", {
238
239
  className,
239
- target: { type: "resolve", kind: "version", value: version },
240
+ target: { kind: "version", value: version },
240
241
  ...(mapping ? { mapping } : {}),
241
242
  mode: "metadata",
242
243
  ...classSourceContext
@@ -5,13 +5,15 @@
5
5
  /* ------------------------------------------------------------------ */
6
6
  /* Regex patterns */
7
7
  /* ------------------------------------------------------------------ */
8
- const CLASS_DECL_RE = /(?:public\s+)?(?:abstract\s+)?(?:class|interface)\s+(\w+)/;
8
+ const CLASS_DECL_RE = /^\s*(?:@[\w$.]+(?:\([^)]*\))?\s+)*(?:public\s+|final\s+|abstract\s+|sealed\s+)*\b(?:class|interface)\s+(\w+)/;
9
9
  // import statements for FQCN resolution
10
10
  const IMPORT_RE = /^\s*import\s+([\w.]+)\s*;/;
11
11
  // @Mixin(Foo.class) or @Mixin({Foo.class, Bar.class}) or @Mixin(value = Foo.class)
12
12
  // Also handles @Mixin(value = {Foo.class, Bar.class}, priority = 900)
13
13
  const MIXIN_ANNOTATION_START_RE = /^\s*@Mixin\s*\(/;
14
- const MIXIN_TARGET_RE = /(\w[\w.]*?)\.class/g;
14
+ // `$` is a legal Java identifier character (e.g. generated classes like My$Class);
15
+ // without it the capture would start mid-name and yield a wrong target.
16
+ const MIXIN_TARGET_RE = /([\w$][\w.$]*?)\.class/g;
15
17
  const MIXIN_PRIORITY_RE = /priority\s*=\s*(\d+)/;
16
18
  // String-form targets: @Mixin(targets = "pkg.Class") or @Mixin(targets = {"pkg.A", "pkg.B"})
17
19
  const MIXIN_TARGETS_STRING_RE = /targets\s*=\s*(?:\{([^}]+)\}|"([^"]+)")/;
@@ -55,15 +57,100 @@ function collectMultilineAnnotation(lines, startIndex) {
55
57
  }
56
58
  return { text, endIndex: lines.length - 1 };
57
59
  }
60
+ /**
61
+ * Blank out `/* *\/` block comments and `//` line comments (outside string literals)
62
+ * while preserving line count and line numbers, so the annotation scans never match
63
+ * inside commented-out code.
64
+ */
65
+ function stripComments(source) {
66
+ let out = "";
67
+ let inBlock = false;
68
+ let inString = false;
69
+ for (let i = 0; i < source.length; i++) {
70
+ const ch = source[i];
71
+ const next = source[i + 1];
72
+ if (inBlock) {
73
+ if (ch === "*" && next === "/") {
74
+ out += " ";
75
+ i++;
76
+ inBlock = false;
77
+ }
78
+ else {
79
+ out += ch === "\n" ? "\n" : " ";
80
+ }
81
+ continue;
82
+ }
83
+ if (inString) {
84
+ out += ch;
85
+ if (ch === "\\" && next !== undefined) {
86
+ out += next;
87
+ i++;
88
+ }
89
+ else if (ch === '"') {
90
+ inString = false;
91
+ }
92
+ continue;
93
+ }
94
+ if (ch === '"') {
95
+ inString = true;
96
+ out += ch;
97
+ continue;
98
+ }
99
+ if (ch === "/" && next === "*") {
100
+ out += " ";
101
+ i++;
102
+ inBlock = true;
103
+ continue;
104
+ }
105
+ if (ch === "/" && next === "/") {
106
+ while (i < source.length && source[i] !== "\n") {
107
+ i++;
108
+ }
109
+ i--;
110
+ continue;
111
+ }
112
+ out += ch;
113
+ }
114
+ return out;
115
+ }
58
116
  /**
59
117
  * Skip past annotations (including multi-line ones) starting at `startIndex`.
60
118
  * Returns the index of the first non-annotation line.
61
119
  * Lines with inline annotation + declaration (e.g. `@Nullable private int x;`)
62
120
  * are treated as declaration lines (not skipped).
63
121
  */
122
+ function isUpperCaseLetter(ch) {
123
+ return ch !== ch.toLowerCase() && ch === ch.toUpperCase();
124
+ }
64
125
  function skipAnnotations(lines, startIndex) {
65
126
  let idx = startIndex;
66
- while (idx < lines.length && /^\s*@/.test(lines[idx])) {
127
+ while (idx < lines.length) {
128
+ const current = lines[idx];
129
+ // Skip blank lines and comment-only lines that sit between an annotation and the
130
+ // member declaration; otherwise the declaration line is missed and the member dropped.
131
+ if (/^\s*$/.test(current) || /^\s*\/\//.test(current)) {
132
+ idx++;
133
+ continue;
134
+ }
135
+ if (/^\s*\/\*/.test(current)) {
136
+ if (current.includes("*/")) {
137
+ const afterComment = current.slice(current.indexOf("*/") + 2).trim();
138
+ if (afterComment === "") {
139
+ idx++;
140
+ continue;
141
+ }
142
+ break; // declaration text follows an inline block comment
143
+ }
144
+ idx++;
145
+ while (idx < lines.length && !lines[idx].includes("*/")) {
146
+ idx++;
147
+ }
148
+ idx++; // move past the closing "*/" line
149
+ continue;
150
+ }
151
+ if (!/^\s*@/.test(current)) {
152
+ break;
153
+ }
67
154
  if (lines[idx].includes("(")) {
68
155
  // Check if parentheses are unbalanced (multi-line annotation) — always skip
69
156
  let depth = 0;
@@ -106,18 +193,33 @@ const INLINE_ANNOTATION_RE = /\s*@[\w$.]+(?:\([^)]*\))?\s*/g;
106
193
  function stripInlineAnnotations(line) {
107
194
  return line.replace(INLINE_ANNOTATION_RE, " ").trim();
108
195
  }
196
+ /**
197
+ * JavaBeans `Introspector.decapitalize`, which SpongePowered Mixin's AccessorInfo
198
+ * uses for inflection: leave the name unchanged when its first two characters are
199
+ * both upper case (getURL -> URL, getNBT -> NBT), otherwise lower-case the first
200
+ * character (getId -> id, getHealth -> health).
201
+ */
202
+ function decapitalize(name) {
203
+ if (name.length === 0) {
204
+ return name;
205
+ }
206
+ if (name.length > 1 && isUpperCaseLetter(name.charAt(1)) && isUpperCaseLetter(name.charAt(0))) {
207
+ return name;
208
+ }
209
+ return name.charAt(0).toLowerCase() + name.slice(1);
210
+ }
109
211
  function inferAccessorTarget(methodName) {
110
212
  const getterMatch = GETTER_PREFIX_RE.exec(methodName);
111
213
  if (getterMatch) {
112
- return getterMatch[1].charAt(0).toLowerCase() + getterMatch[1].slice(1);
214
+ return decapitalize(getterMatch[1]);
113
215
  }
114
216
  const setterMatch = SETTER_PREFIX_RE.exec(methodName);
115
217
  if (setterMatch) {
116
- return setterMatch[1].charAt(0).toLowerCase() + setterMatch[1].slice(1);
218
+ return decapitalize(setterMatch[1]);
117
219
  }
118
220
  const invokerMatch = INVOKER_PREFIX_RE.exec(methodName);
119
221
  if (invokerMatch) {
120
- return invokerMatch[1].charAt(0).toLowerCase() + invokerMatch[1].slice(1);
222
+ return decapitalize(invokerMatch[1]);
121
223
  }
122
224
  return methodName;
123
225
  }
@@ -125,7 +227,7 @@ function inferAccessorTarget(methodName) {
125
227
  /* Main parser */
126
228
  /* ------------------------------------------------------------------ */
127
229
  export function parseMixinSource(source) {
128
- const lines = source.split(/\r?\n/);
230
+ const lines = stripComments(source).split(/\r?\n/);
129
231
  const parseWarnings = [];
130
232
  const targets = [];
131
233
  const injections = [];
@@ -279,6 +381,16 @@ export function parseMixinSource(source) {
279
381
  }
280
382
  i = endIndex;
281
383
  }
384
+ // Try same-line declaration first (e.g. `@Accessor("x") int getX();`)
385
+ const inlineDecl = stripInlineAnnotations(line);
386
+ const inlineMethodMatch = inlineDecl.includes("(") ? METHOD_DECL_RE.exec(inlineDecl) : null;
387
+ if (inlineMethodMatch) {
388
+ const methodName = inlineMethodMatch[2];
389
+ const targetName = explicitTarget ?? inferAccessorTarget(methodName);
390
+ accessors.push({ annotation, name: methodName, targetName, line: lineNum });
391
+ i++;
392
+ continue;
393
+ }
282
394
  // Find the method declaration following the annotation (skip multi-line annotations)
283
395
  let methodLine = i + 1;
284
396
  methodLine = skipAnnotations(lines, methodLine);
@@ -43,8 +43,10 @@ export type GetModClassSourceOutput = {
43
43
  export declare class ModDecompileService {
44
44
  private readonly config;
45
45
  private readonly decompileCache;
46
+ private readonly inflightDecompiles;
46
47
  constructor(config: Config);
47
48
  decompileModJar(input: DecompileModJarInput): Promise<DecompileModJarOutput>;
48
49
  getModClassSource(input: GetModClassSourceInput): Promise<GetModClassSourceOutput>;
49
50
  private ensureDecompiled;
51
+ private decompileAndCache;
50
52
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { statSync } from "node:fs";
2
3
  import { readFile, writeFile } from "node:fs/promises";
3
4
  import { isAbsolute, join, resolve as resolvePath } from "node:path";
4
5
  import { createError, ERROR_CODES } from "./errors.js";
@@ -6,10 +7,12 @@ import { log } from "./logger.js";
6
7
  import { decompileBinaryJar } from "./decompiler/vineflower.js";
7
8
  import { resolveVineflowerJar } from "./vineflower-resolver.js";
8
9
  import { analyzeModJar } from "./mod-analyzer.js";
9
- import { validateAndNormalizeJarPath } from "./path-resolver.js";
10
+ import { buildJarSignature, validateAndNormalizeJarPath } from "./path-resolver.js";
11
+ import { sliceToMaxCharsSafe } from "./text-truncate.js";
10
12
  const DECOMPILE_TIMEOUT_MS = 300_000;
11
13
  function modDecompileCacheKey(jarPath) {
12
- return createHash("sha256").update(jarPath).digest("hex");
14
+ const stats = statSync(jarPath);
15
+ return createHash("sha256").update(`${jarPath}|${buildJarSignature(stats)}`).digest("hex");
13
16
  }
14
17
  function classNameToFilePath(className) {
15
18
  return className.replaceAll(".", "/") + ".java";
@@ -27,6 +30,7 @@ export class ModDecompileService {
27
30
  config;
28
31
  // Cache: jarPath hash → decompiled output dir + file list
29
32
  decompileCache = new Map();
33
+ inflightDecompiles = new Map();
30
34
  constructor(config) {
31
35
  this.config = config;
32
36
  }
@@ -122,7 +126,7 @@ export class ModDecompileService {
122
126
  }
123
127
  // Apply maxChars truncation
124
128
  if (input.maxChars != null && input.maxChars > 0 && content.length > input.maxChars) {
125
- content = content.slice(0, input.maxChars);
129
+ content = sliceToMaxCharsSafe(content, input.maxChars);
126
130
  charsTruncated = true;
127
131
  truncated = true;
128
132
  }
@@ -154,6 +158,20 @@ export class ModDecompileService {
154
158
  this.decompileCache.set(cacheKey, cached);
155
159
  return cached;
156
160
  }
161
+ const existingDecompile = this.inflightDecompiles.get(cacheKey);
162
+ if (existingDecompile) {
163
+ return existingDecompile;
164
+ }
165
+ const decompilePromise = this.decompileAndCache(jarPath, cacheKey, warnings);
166
+ this.inflightDecompiles.set(cacheKey, decompilePromise);
167
+ try {
168
+ return await decompilePromise;
169
+ }
170
+ finally {
171
+ this.inflightDecompiles.delete(cacheKey);
172
+ }
173
+ }
174
+ async decompileAndCache(jarPath, cacheKey, warnings) {
157
175
  log("info", "mod-decompile.start", { jarPath });
158
176
  const startedAt = Date.now();
159
177
  // Analyze mod metadata
@@ -1,9 +1,18 @@
1
+ import { resolveTinyMappingFile } from "./mapping-service.js";
2
+ import { resolveMojangTinyFile } from "./mojang-tiny-mapping-service.js";
3
+ import { remapJar } from "./tiny-remapper-service.js";
4
+ import { resolveTinyRemapperJar } from "./tiny-remapper-resolver.js";
1
5
  import type { Config } from "./types.js";
2
6
  export interface ModRemapInput {
3
7
  inputJar: string;
4
8
  outputJar?: string;
5
9
  mcVersion?: string;
6
10
  targetMapping: "yarn" | "mojang";
11
+ /**
12
+ * Skip the remap cache and force re-resolution of the newest yarn build
13
+ * (bypasses both the output-jar cache hit and the yarn metadata TTL).
14
+ */
15
+ forceRemap?: boolean;
7
16
  }
8
17
  export interface ModRemapResult {
9
18
  outputJar: string;
@@ -14,4 +23,14 @@ export interface ModRemapResult {
14
23
  durationMs: number;
15
24
  warnings: string[];
16
25
  }
17
- export declare function remapModJar(input: ModRemapInput, config: Config): Promise<ModRemapResult>;
26
+ /**
27
+ * Injectable external dependencies, used by tests to drive the remap pipeline
28
+ * without spawning Java or downloading mapping jars. Production callers omit it.
29
+ */
30
+ export interface RemapModJarDeps {
31
+ remapJar?: typeof remapJar;
32
+ resolveTinyMappingFile?: typeof resolveTinyMappingFile;
33
+ resolveMojangTinyFile?: typeof resolveMojangTinyFile;
34
+ resolveTinyRemapperJar?: typeof resolveTinyRemapperJar;
35
+ }
36
+ export declare function remapModJar(input: ModRemapInput, config: Config, deps?: RemapModJarDeps): Promise<ModRemapResult>;
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { copyFileSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { createError, ERROR_CODES, isAppError } from "./errors.js";
@@ -11,6 +11,13 @@ import { normalizePathForHost } from "./path-converter.js";
11
11
  import { detectFabricLikeInputNamespace } from "./source-jar-reader.js";
12
12
  import { remapJar } from "./tiny-remapper-service.js";
13
13
  import { resolveTinyRemapperJar } from "./tiny-remapper-resolver.js";
14
+ // Bumped whenever the remap pipeline changes in a way that makes previously
15
+ // cached output jars wrong. The v1 pipeline mis-mapped intermediary jars to
16
+ // mojang using an obfuscated<->mojang tiny (no intermediary namespace), so any
17
+ // cached mojang output is invalid and must not be reused. v3 adds the resolved
18
+ // mapping identity (yarn build coordinate) to the cache key so a newer yarn
19
+ // build no longer serves the stale jar; the bump invalidates v2 outputs once.
20
+ const REMAP_PIPELINE_VERSION = "v3";
14
21
  function normalizeTargetNamespace(target) {
15
22
  return target === "yarn" ? "yarn" : "mojang";
16
23
  }
@@ -58,14 +65,18 @@ function copyJarToDestination(sourceJar, destinationJar) {
58
65
  mkdirSync(dirname(destinationJar), { recursive: true });
59
66
  copyFileSync(sourceJar, destinationJar);
60
67
  }
61
- function buildCacheKey(inputJar, fromNamespace, targetNamespace, mcVersion) {
68
+ function buildCacheKey(inputJar, fromNamespace, targetNamespace, mcVersion, mappingIdentity) {
62
69
  const stat = statSync(inputJar, { throwIfNoEntry: false });
63
70
  const signature = stat ? `${stat.mtimeMs}:${stat.size}` : "unknown";
64
71
  return createHash("sha256")
65
- .update(`${inputJar}|${signature}|${fromNamespace}|${targetNamespace}|${mcVersion}`)
72
+ .update(`${REMAP_PIPELINE_VERSION}|${inputJar}|${signature}|${fromNamespace}|${targetNamespace}|${mcVersion}|${mappingIdentity}`)
66
73
  .digest("hex");
67
74
  }
68
- export async function remapModJar(input, config) {
75
+ export async function remapModJar(input, config, deps = {}) {
76
+ const remapJarFn = deps.remapJar ?? remapJar;
77
+ const resolveTinyMappingFileFn = deps.resolveTinyMappingFile ?? resolveTinyMappingFile;
78
+ const resolveMojangTinyFileFn = deps.resolveMojangTinyFile ?? resolveMojangTinyFile;
79
+ const resolveTinyRemapperJarFn = deps.resolveTinyRemapperJar ?? resolveTinyRemapperJar;
69
80
  const startedAt = Date.now();
70
81
  const warnings = [];
71
82
  // 1. Normalize input JAR path
@@ -111,12 +122,33 @@ export async function remapModJar(input, config) {
111
122
  });
112
123
  }
113
124
  const outputJar = resolveOutputJarPath(input, normalizedInput, analysis.modId, analysis.modVersion);
114
- // 4. Check cache after mapping context is known
115
- const cacheKey = buildCacheKey(normalizedInput, fromNamespace, resolvedTargetNamespace, mcVersion);
125
+ // 4. Resolve the mapping identity BEFORE the cache key so the key reflects
126
+ // which mappings produced the jar. Yarn builds are mutable (Fabric publishes
127
+ // new builds per MC version), so the resolved yarn coordinate is the identity;
128
+ // mojang is immutable per release so its version-keyed path string suffices.
129
+ // The yarn resolution is TTL-fenced (see resolveTinyMappingFile), so on the
130
+ // common warm path this is zero-network and stays off the cache-hit fast path.
131
+ let mappingIdentity;
132
+ let resolvedYarnTiny;
133
+ if (fromNamespace === resolvedTargetNamespace) {
134
+ // No remap will run (input already in target namespace); the output is a
135
+ // copy of the input, so no mapping identity is involved.
136
+ mappingIdentity = `copy:${resolvedTargetNamespace}`;
137
+ }
138
+ else if (resolvedTargetNamespace === "yarn") {
139
+ resolvedYarnTiny = await resolveTinyMappingFileFn(mcVersion, "yarn", config.cacheDir, undefined, {
140
+ forceRefresh: input.forceRemap
141
+ });
142
+ mappingIdentity = resolvedYarnTiny.coordinate ?? resolvedYarnTiny.path;
143
+ }
144
+ else {
145
+ mappingIdentity = `${mcVersion}-mojang-merged.tiny`;
146
+ }
147
+ const cacheKey = buildCacheKey(normalizedInput, fromNamespace, resolvedTargetNamespace, mcVersion, mappingIdentity);
116
148
  const cacheDir = join(config.cacheDir, "remapped-mods");
117
149
  mkdirSync(cacheDir, { recursive: true });
118
150
  const cachedOutput = join(cacheDir, `${cacheKey}.jar`);
119
- if (existsSync(cachedOutput)) {
151
+ if (!input.forceRemap && existsSync(cachedOutput)) {
120
152
  const cacheHitOutputJar = input.outputJar
121
153
  ? outputJar
122
154
  : cachedOutput;
@@ -161,57 +193,81 @@ export async function remapModJar(input, config) {
161
193
  });
162
194
  }
163
195
  // 5. Resolve tiny-remapper
164
- const tinyRemapperJar = await resolveTinyRemapperJar(config.cacheDir, config.tinyRemapperJarPath);
165
- // 6. Resolve mapping file and remap
166
- let mappingsFile;
167
- let toNamespace;
196
+ const tinyRemapperJar = await resolveTinyRemapperJarFn(config.cacheDir, config.tinyRemapperJarPath);
197
+ const passes = [];
168
198
  if (resolvedTargetNamespace === "yarn") {
169
- mappingsFile = await resolveTinyMappingFile(mcVersion, "yarn", config.cacheDir);
170
- toNamespace = "named";
199
+ // The Fabric yarn v2 tiny declares both intermediary and named, so a single
200
+ // intermediary -> named pass is sufficient.
201
+ // Reuse the coordinate resolved for the cache key (already TTL-fenced).
202
+ const yarnTiny = resolvedYarnTiny ?? await resolveTinyMappingFileFn(mcVersion, "yarn", config.cacheDir, undefined, {
203
+ forceRefresh: input.forceRemap
204
+ });
205
+ passes.push({ mappingsFile: yarnTiny.path, fromNamespace, toNamespace: "named" });
171
206
  }
172
207
  else {
173
- const mojangTiny = await resolveMojangTinyFile(mcVersion, config);
174
- mappingsFile = mojangTiny.path;
175
- toNamespace = "mojang";
208
+ // Mojang target. The merged mojang tiny only bridges obfuscated <-> mojang,
209
+ // but a Fabric/Quilt jar is intermediary-named, so a direct intermediary ->
210
+ // mojang remap finds no matching keys and silently no-ops. Remap in two passes:
211
+ // pass 1: intermediary -> official (= obfuscated) via the Fabric intermediary v2 tiny
212
+ // pass 2: obfuscated -> mojang via the merged mojang tiny
213
+ const mojangTiny = await resolveMojangTinyFileFn(mcVersion, config);
176
214
  warnings.push(...mojangTiny.warnings);
215
+ if (fromNamespace === "intermediary") {
216
+ const intermediaryTiny = await resolveTinyMappingFileFn(mcVersion, "intermediary", config.cacheDir);
217
+ passes.push({ mappingsFile: intermediaryTiny.path, fromNamespace: "intermediary", toNamespace: "official" });
218
+ passes.push({ mappingsFile: mojangTiny.path, fromNamespace: "obfuscated", toNamespace: "mojang" });
219
+ }
220
+ else {
221
+ // Defensive: an already-obfuscated input maps directly. (mojang -> mojang is
222
+ // handled earlier by the copy short-circuit, so this is not normally reached.)
223
+ passes.push({ mappingsFile: mojangTiny.path, fromNamespace: "obfuscated", toNamespace: "mojang" });
224
+ }
177
225
  }
178
226
  mkdirSync(dirname(outputJar), { recursive: true });
179
- // 8. Use temporary directory for intermediate work
180
- const tempDir = join(tmpdir(), `mcp-remap-${cacheKey.slice(0, 12)}`);
181
- mkdirSync(tempDir, { recursive: true });
227
+ // 7. Use a temporary directory for intermediate work
228
+ const tempDir = mkdtempSync(join(tmpdir(), `mcp-remap-${cacheKey.slice(0, 12)}-`));
182
229
  try {
183
- const tempOutput = join(tempDir, "remapped.jar");
184
- try {
185
- await remapJar(tinyRemapperJar, {
186
- inputJar: normalizedInput,
187
- outputJar: tempOutput,
188
- mappingsFile,
189
- fromNamespace,
190
- toNamespace,
191
- timeoutMs: config.remapTimeoutMs,
192
- maxMemoryMb: config.remapMaxMemoryMb
193
- });
194
- }
195
- catch (caughtError) {
196
- if (isAppError(caughtError)) {
197
- throw createError({
198
- code: caughtError.code,
199
- message: caughtError.message,
200
- details: {
201
- ...(caughtError.details ?? {}),
202
- fromMapping: fromNamespace,
203
- targetMapping: input.targetMapping,
204
- resolvedTargetNamespace
205
- }
230
+ let passInput = normalizedInput;
231
+ let tempOutput = "";
232
+ for (let passIndex = 0; passIndex < passes.length; passIndex += 1) {
233
+ const pass = passes[passIndex];
234
+ tempOutput = join(tempDir, `pass-${passIndex}.jar`);
235
+ try {
236
+ await remapJarFn(tinyRemapperJar, {
237
+ inputJar: passInput,
238
+ outputJar: tempOutput,
239
+ mappingsFile: pass.mappingsFile,
240
+ fromNamespace: pass.fromNamespace,
241
+ toNamespace: pass.toNamespace,
242
+ timeoutMs: config.remapTimeoutMs,
243
+ maxMemoryMb: config.remapMaxMemoryMb
206
244
  });
207
245
  }
208
- throw caughtError;
246
+ catch (caughtError) {
247
+ if (isAppError(caughtError)) {
248
+ throw createError({
249
+ code: caughtError.code,
250
+ message: caughtError.message,
251
+ details: {
252
+ ...(caughtError.details ?? {}),
253
+ fromMapping: fromNamespace,
254
+ targetMapping: input.targetMapping,
255
+ resolvedTargetNamespace,
256
+ pass: `${pass.fromNamespace}->${pass.toNamespace}`
257
+ }
258
+ });
259
+ }
260
+ throw caughtError;
261
+ }
262
+ passInput = tempOutput;
209
263
  }
210
264
  // Copy to final destination and cache
211
265
  copyFileSync(tempOutput, outputJar);
212
266
  if (outputJar !== cachedOutput) {
213
267
  mkdirSync(dirname(cachedOutput), { recursive: true });
214
- copyFileSync(tempOutput, cachedOutput);
268
+ const tempCachedOutput = `${cachedOutput}.${randomBytes(4).toString("hex")}.tmp`;
269
+ copyFileSync(tempOutput, tempCachedOutput);
270
+ renameSync(tempCachedOutput, cachedOutput);
215
271
  }
216
272
  const durationMs = Date.now() - startedAt;
217
273
  log("info", "remap.pipeline.done", {