@adhisang/minecraft-modding-mcp 3.1.1 → 4.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 (61) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +37 -18
  3. package/dist/access-transformer-parser.d.ts +17 -0
  4. package/dist/access-transformer-parser.js +97 -0
  5. package/dist/cache-registry.d.ts +1 -1
  6. package/dist/cache-registry.js +10 -2
  7. package/dist/concurrency.d.ts +1 -0
  8. package/dist/concurrency.js +24 -0
  9. package/dist/config.d.ts +10 -1
  10. package/dist/config.js +52 -1
  11. package/dist/decompiler/vineflower.js +22 -21
  12. package/dist/entry-tools/analyze-mod-service.d.ts +4 -4
  13. package/dist/entry-tools/analyze-symbol-service.d.ts +22 -22
  14. package/dist/entry-tools/analyze-symbol-service.js +13 -2
  15. package/dist/entry-tools/inspect-minecraft-service.d.ts +168 -168
  16. package/dist/entry-tools/inspect-minecraft-service.js +8 -2
  17. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  18. package/dist/entry-tools/validate-project-service.d.ts +153 -16
  19. package/dist/entry-tools/validate-project-service.js +442 -25
  20. package/dist/gradle-paths.d.ts +4 -0
  21. package/dist/gradle-paths.js +57 -0
  22. package/dist/index.js +148 -30
  23. package/dist/lru-list.d.ts +31 -0
  24. package/dist/lru-list.js +102 -0
  25. package/dist/mapping-pipeline-service.d.ts +12 -1
  26. package/dist/mapping-pipeline-service.js +28 -1
  27. package/dist/mapping-service.d.ts +16 -0
  28. package/dist/mapping-service.js +405 -68
  29. package/dist/minecraft-explorer-service.d.ts +13 -0
  30. package/dist/minecraft-explorer-service.js +8 -4
  31. package/dist/mixin-validator.d.ts +33 -2
  32. package/dist/mixin-validator.js +218 -17
  33. package/dist/mod-analyzer.d.ts +1 -0
  34. package/dist/mod-analyzer.js +17 -1
  35. package/dist/mod-decompile-service.js +4 -4
  36. package/dist/mod-remap-service.js +1 -54
  37. package/dist/mod-search-service.d.ts +1 -0
  38. package/dist/mod-search-service.js +84 -51
  39. package/dist/observability.d.ts +18 -1
  40. package/dist/observability.js +44 -1
  41. package/dist/response-utils.d.ts +69 -0
  42. package/dist/response-utils.js +227 -0
  43. package/dist/source-jar-reader.d.ts +16 -0
  44. package/dist/source-jar-reader.js +103 -1
  45. package/dist/source-resolver.d.ts +9 -1
  46. package/dist/source-resolver.js +23 -16
  47. package/dist/source-service.d.ts +119 -3
  48. package/dist/source-service.js +1836 -218
  49. package/dist/storage/artifacts-repo.d.ts +4 -1
  50. package/dist/storage/artifacts-repo.js +33 -5
  51. package/dist/storage/files-repo.d.ts +0 -2
  52. package/dist/storage/files-repo.js +0 -11
  53. package/dist/storage/migrations.d.ts +1 -1
  54. package/dist/storage/migrations.js +10 -2
  55. package/dist/storage/schema.d.ts +2 -0
  56. package/dist/storage/schema.js +25 -0
  57. package/dist/tool-contract-manifest.js +8 -6
  58. package/dist/types.d.ts +20 -0
  59. package/dist/workspace-mapping-service.d.ts +13 -0
  60. package/dist/workspace-mapping-service.js +146 -14
  61. package/package.json +3 -1
@@ -3,8 +3,9 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import fastGlob from "fast-glob";
5
5
  import { createError, ERROR_CODES } from "./errors.js";
6
+ import { buildVersionSourceSearchRoots, normalizeOptionalProjectPath } from "./gradle-paths.js";
6
7
  import { defaultDownloadPath, downloadToCache } from "./repo-downloader.js";
7
- import { listJarEntries, readJarEntryAsUtf8 } from "./source-jar-reader.js";
8
+ import { collectMatchedJarEntriesAsUtf8 } from "./source-jar-reader.js";
8
9
  import { VersionService, isUnobfuscatedVersion } from "./version-service.js";
9
10
  const SUPPORTED_MAPPINGS = new Set([
10
11
  "obfuscated",
@@ -19,6 +20,7 @@ const MATCH_RANK = {
19
20
  };
20
21
  const DESCRIPTOR_FALLBACK_CONFIDENCE = 0.85;
21
22
  const MAX_CANDIDATES = 200;
23
+ const GLOB_SPECIAL_CHARS = /[\\!*+?()[\]{}@|]/g;
22
24
  function createDirectionIndex() {
23
25
  return {
24
26
  exact: new Map(),
@@ -706,15 +708,93 @@ function normalizeMemberName(name) {
706
708
  }
707
709
  return normalized;
708
710
  }
711
+ /**
712
+ * Validate a JVM method descriptor such as `(I)V`, `()Lfoo/Bar;`, `(Lfoo/Bar;[I)V`.
713
+ * Rejects empty strings, missing/mis-positioned parens, empty return type, and invalid base
714
+ * type tokens so "(" or "()" style half-descriptors surface as ERR_INVALID_INPUT instead of
715
+ * being silently accepted.
716
+ */
709
717
  function normalizeMethodDescriptor(descriptor) {
710
718
  const normalized = descriptor?.trim() ?? "";
711
- if (!normalized || !normalized.startsWith("(") || !normalized.includes(")")) {
719
+ if (!normalized) {
720
+ throw invalidInputError("descriptor must be a valid JVM descriptor when kind=method.", {
721
+ descriptor
722
+ });
723
+ }
724
+ if (!isValidMethodDescriptor(normalized)) {
712
725
  throw invalidInputError("descriptor must be a valid JVM descriptor when kind=method.", {
713
726
  descriptor
714
727
  });
715
728
  }
716
729
  return normalized;
717
730
  }
731
+ function isValidMethodDescriptor(descriptor) {
732
+ if (!descriptor.startsWith("("))
733
+ return false;
734
+ const closingIndex = descriptor.indexOf(")");
735
+ if (closingIndex < 0)
736
+ return false;
737
+ const argsSection = descriptor.slice(1, closingIndex);
738
+ const returnSection = descriptor.slice(closingIndex + 1);
739
+ if (returnSection.length === 0)
740
+ return false;
741
+ let cursor = 0;
742
+ while (cursor < argsSection.length) {
743
+ const next = consumeFieldType(argsSection, cursor, /*allowVoid*/ false);
744
+ if (next < 0)
745
+ return false;
746
+ cursor = next;
747
+ }
748
+ const returnEnd = consumeFieldType(returnSection, 0, /*allowVoid*/ true);
749
+ return returnEnd === returnSection.length;
750
+ }
751
+ /**
752
+ * JVM specification §4.3.2: "An array type descriptor is valid only if it represents a type
753
+ * with 255 or fewer dimensions." Matches the `multianewarray` / field-signature limit.
754
+ */
755
+ const JVM_MAX_ARRAY_DIMENSIONS = 255;
756
+ function consumeFieldType(descriptor, position, allowVoid) {
757
+ // Arrays are handled iteratively so pathological inputs such as `(` + "[".repeat(20000) + `I)V`
758
+ // cannot blow the call stack. After consuming every leading `[`, only the element type token
759
+ // is dispatched through the switch below. Dimensions above the JVM limit are rejected rather
760
+ // than merely accepted as "syntactically valid but semantically absurd" — clients must not be
761
+ // able to push a 20000-dimension descriptor through cache-key construction.
762
+ let cursor = position;
763
+ let arrayDimensions = 0;
764
+ while (cursor < descriptor.length && descriptor[cursor] === "[") {
765
+ cursor += 1;
766
+ arrayDimensions += 1;
767
+ if (arrayDimensions > JVM_MAX_ARRAY_DIMENSIONS)
768
+ return -1;
769
+ }
770
+ if (cursor >= descriptor.length)
771
+ return -1;
772
+ // Void is only valid at the outermost position — inside an array element it is illegal.
773
+ const elementAllowsVoid = cursor === position && allowVoid;
774
+ const token = descriptor[cursor];
775
+ switch (token) {
776
+ case "B":
777
+ case "C":
778
+ case "D":
779
+ case "F":
780
+ case "I":
781
+ case "J":
782
+ case "S":
783
+ case "Z":
784
+ return cursor + 1;
785
+ case "V":
786
+ return elementAllowsVoid ? cursor + 1 : -1;
787
+ case "L": {
788
+ const end = descriptor.indexOf(";", cursor);
789
+ // Reject empty class names like L; and unterminated references.
790
+ if (end < 0 || end === cursor + 1)
791
+ return -1;
792
+ return end + 1;
793
+ }
794
+ default:
795
+ return -1;
796
+ }
797
+ }
718
798
  function normalizeQuerySymbol(input, signatureMode, options) {
719
799
  if (input.kind !== "class" && input.kind !== "field" && input.kind !== "method") {
720
800
  throw invalidInputError('kind must be one of "class", "field", or "method".', {
@@ -771,9 +851,19 @@ function normalizeQuerySymbol(input, signatureMode, options) {
771
851
  querySymbol: toSymbolReference(record)
772
852
  };
773
853
  }
774
- const descriptor = signatureMode === "name-only"
775
- ? (input.descriptor?.trim() || "")
776
- : normalizeMethodDescriptor(input.descriptor);
854
+ let descriptor;
855
+ if (signatureMode === "name-only") {
856
+ // name-only matches by owner+name only; a supplied descriptor is validated (so malformed
857
+ // input still surfaces as ERR_INVALID_INPUT) but discarded afterwards so downstream
858
+ // projection / filtering treats the query as "no descriptor".
859
+ if (input.descriptor?.trim()) {
860
+ normalizeMethodDescriptor(input.descriptor);
861
+ }
862
+ descriptor = "";
863
+ }
864
+ else {
865
+ descriptor = normalizeMethodDescriptor(input.descriptor);
866
+ }
777
867
  const record = createMethodSymbolRecord(owner, normalizeMemberName(normalizedName), descriptor);
778
868
  return {
779
869
  record,
@@ -811,13 +901,32 @@ function applyDisambiguationHints(candidates, disambiguation) {
811
901
  }
812
902
  const descriptorHint = normalizeDescriptorHint(disambiguation.descriptorHint);
813
903
  if (descriptorHint) {
814
- const descriptorMatched = filtered.filter((candidate) => candidate.descriptor === descriptorHint);
904
+ const descriptorMatched = filtered.filter((candidate) => candidate.descriptor != null && candidate.descriptor === descriptorHint);
815
905
  if (descriptorMatched.length > 0) {
816
906
  filtered = descriptorMatched;
817
907
  }
818
908
  }
819
909
  return filtered;
820
910
  }
911
+ function projectLookupCandidateDescriptor(candidate, sourceDescriptor, targetDescriptor) {
912
+ // Tiny mappings preserve method descriptors verbatim, so single-hop tiny paths often
913
+ // return the source descriptor even though the final symbol is already in the target
914
+ // namespace. Multi-hop paths that already produced a target-side descriptor are left
915
+ // unchanged by design.
916
+ if (candidate.kind !== "method" ||
917
+ !candidate.descriptor ||
918
+ !targetDescriptor ||
919
+ candidate.descriptor !== sourceDescriptor) {
920
+ return candidate;
921
+ }
922
+ return {
923
+ ...candidate,
924
+ descriptor: targetDescriptor
925
+ };
926
+ }
927
+ function effectiveLoomSearchProjectPath(projectPath) {
928
+ return normalizeOptionalProjectPath(projectPath) ?? normalizeOptionalProjectPath(process.cwd());
929
+ }
821
930
  function collectTargetRecords(graph, targetMapping) {
822
931
  return [...(graph.recordsByTarget.get(targetMapping) ?? [])];
823
932
  }
@@ -895,6 +1004,18 @@ export class MappingService {
895
1004
  fetchFn;
896
1005
  graphCache = new Map();
897
1006
  buildLocks = new Map();
1007
+ resolutionCache = new Map();
1008
+ static RESOLUTION_CACHE_MAX = 512;
1009
+ static RESOLUTION_CACHE_TTL_MS = 5 * 60 * 1000;
1010
+ resolutionCacheHits = 0;
1011
+ resolutionCacheMisses = 0;
1012
+ get resolutionCacheStats() {
1013
+ return {
1014
+ hits: this.resolutionCacheHits,
1015
+ misses: this.resolutionCacheMisses,
1016
+ size: this.resolutionCache.size
1017
+ };
1018
+ }
898
1019
  constructor(config, versionService = new VersionService(config), fetchFn = globalThis.fetch) {
899
1020
  this.config = config;
900
1021
  this.versionService = versionService;
@@ -911,9 +1032,25 @@ export class MappingService {
911
1032
  }
912
1033
  });
913
1034
  }
914
- const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, input.signatureMode, {
1035
+ // Normalize the effective signatureMode exactly once so every downstream path — query
1036
+ // symbol normalization, the strict-overload filter, the cache key, and warning text —
1037
+ // sees the same value. The public tool schema defaults to "name-only", so an omitted
1038
+ // signatureMode reaching the service (e.g. internal callers, MCP resource handlers, the
1039
+ // resolution cache) must default to "name-only" too, otherwise the service contradicts
1040
+ // the advertised default and silently reverts to the old descriptor-required path.
1041
+ // Callers that genuinely need strict descriptor matching pass `signatureMode: "exact"`
1042
+ // explicitly.
1043
+ const effectiveSignatureMode = input.signatureMode ?? "name-only";
1044
+ const { record: queryRecord, querySymbol } = normalizeQuerySymbol(input, effectiveSignatureMode, {
915
1045
  allowShortClassName: input.kind === "class" && input.sourceMapping === "obfuscated"
916
1046
  });
1047
+ const cacheKey = this.buildResolutionCacheKey(version, input, querySymbol, effectiveSignatureMode);
1048
+ const cached = this.resolutionCache.get(cacheKey);
1049
+ if (cached && Date.now() - cached.cachedAt < MappingService.RESOLUTION_CACHE_TTL_MS) {
1050
+ this.resolutionCacheHits += 1;
1051
+ return cached.result;
1052
+ }
1053
+ this.resolutionCacheMisses += 1;
917
1054
  const sourceMapping = input.sourceMapping;
918
1055
  const targetMapping = input.targetMapping;
919
1056
  if (!SUPPORTED_MAPPINGS.has(sourceMapping) || !SUPPORTED_MAPPINGS.has(targetMapping)) {
@@ -953,7 +1090,7 @@ export class MappingService {
953
1090
  warnings: []
954
1091
  };
955
1092
  }
956
- const graph = await this.loadGraph(version, priority, requiresOnlyObfuscatedMojangGraph(sourceMapping, targetMapping) ? "obfuscated-mojang-only" : "full");
1093
+ const graph = await this.loadGraph(version, priority, requiresOnlyObfuscatedMojangGraph(sourceMapping, targetMapping) ? "obfuscated-mojang-only" : "full", input.projectPath);
957
1094
  const path = namespacePath(graph, sourceMapping, targetMapping);
958
1095
  if (!path) {
959
1096
  return {
@@ -968,8 +1105,54 @@ export class MappingService {
968
1105
  ]
969
1106
  };
970
1107
  }
971
- const rawCandidates = this.mapCandidatesAlongPath(graph, path, queryRecord);
1108
+ const descriptorProjection = queryRecord.kind === "method" && queryRecord.descriptor
1109
+ ? this.projectMethodDescriptorToTarget(graph, path, queryRecord.descriptor)
1110
+ : undefined;
1111
+ // Partial projections are still useful for comparison: projectMethodDescriptorToTarget
1112
+ // leaves unmapped `L...;` references unchanged, so JDK / external classes pass through
1113
+ // while Minecraft class references get rewritten to the target namespace. Using the
1114
+ // projected descriptor even when `complete === false` produces descriptors shaped like
1115
+ // the stored records (`(Lnet/minecraft/class_1799;Ljava/lang/String;)V`) and avoids
1116
+ // false negatives for the very common mixed MC + JDK descriptor shape.
1117
+ const projectedDescriptor = descriptorProjection?.hadClassReferences ? descriptorProjection.descriptor : undefined;
1118
+ let rawCandidates = this
1119
+ .mapCandidatesAlongPath(graph, path, queryRecord)
1120
+ .map((candidate) => queryRecord.kind === "method" && queryRecord.descriptor
1121
+ ? projectLookupCandidateDescriptor(candidate, queryRecord.descriptor, projectedDescriptor)
1122
+ : candidate);
972
1123
  const warnings = [];
1124
+ // signatureMode="exact" on kind=method must not return descriptorless fallback candidates
1125
+ // (lookupCandidates adds owner+name fallbacks by design for the loose path). Without this
1126
+ // filter a caller who supplied `foo(I)V` could be told `foo(Z)V` is the exact mapping,
1127
+ // which would be wrong for migration tooling. Mirror resolveMethodMappingExact's strict
1128
+ // behavior: keep only candidates whose descriptor equals the (projected) requested
1129
+ // descriptor. If nothing passes, the normal "candidates.length === 0 -> not_found" path
1130
+ // takes over. Partial projection is accepted here for the same reason as above — we do
1131
+ // not reject mixed MC + JDK descriptors as mapping_unavailable just because the JDK
1132
+ // class reference was not in the mapping graph.
1133
+ if (queryRecord.kind === "method" &&
1134
+ queryRecord.descriptor &&
1135
+ effectiveSignatureMode === "exact") {
1136
+ // Tiny v2 stores a single descriptor per method entry (typically in the obfuscated
1137
+ // namespace) and shares it across every column, while client mappings attach a mojang
1138
+ // descriptor on the mojang side and an obfuscated descriptor on the obfuscated side.
1139
+ // In multi-hop paths (e.g. mojang -> obfuscated -> intermediary -> yarn) the final
1140
+ // candidate's owner and name live in the target namespace but its descriptor can still
1141
+ // be the obfuscated form that rode along the Tiny hop. A strict filter that compared
1142
+ // only against the fully projected target descriptor dropped those valid candidates
1143
+ // and produced false `not_found` for common Mojang -> Yarn lookups. Accept any
1144
+ // candidate whose descriptor matches the caller's descriptor, the target-space
1145
+ // projection, or the obfuscated-space projection — the three forms that actually
1146
+ // appear in the mapping graph.
1147
+ const strictDescriptor = projectedDescriptor ?? queryRecord.descriptor;
1148
+ const acceptedDescriptors = new Set([queryRecord.descriptor, strictDescriptor]);
1149
+ const toObfuscatedPath = namespacePath(graph, sourceMapping, "obfuscated");
1150
+ if (toObfuscatedPath) {
1151
+ const obfuscatedProjection = this.projectMethodDescriptorToTarget(graph, toObfuscatedPath, queryRecord.descriptor);
1152
+ acceptedDescriptors.add(obfuscatedProjection.descriptor);
1153
+ }
1154
+ rawCandidates = rawCandidates.filter((candidate) => candidate.descriptor !== undefined && acceptedDescriptors.has(candidate.descriptor));
1155
+ }
973
1156
  const disambiguatedCandidates = applyDisambiguationHints(rawCandidates, input.disambiguation);
974
1157
  if (rawCandidates.length > disambiguatedCandidates.length) {
975
1158
  warnings.push(`Disambiguation hints narrowed candidates from ${rawCandidates.length} to ${disambiguatedCandidates.length}.`);
@@ -987,12 +1170,21 @@ export class MappingService {
987
1170
  }
988
1171
  else if (candidates.length > 1) {
989
1172
  warnings.push(`Ambiguous mapping: ${candidates.length} candidates matched. Provide a stricter symbol input or disambiguation hints.`);
1173
+ if (queryRecord.kind === "method") {
1174
+ // find-mapping defaults to signatureMode="name-only", which discards any supplied
1175
+ // descriptor. Telling the caller to "add descriptor" would be ineffective unless they
1176
+ // also switch mode, so we point to the exact alternatives instead.
1177
+ warnings.push("Retry with signatureMode=\"exact\" plus a JVM descriptor, or pass disambiguation.descriptorHint, or raise maxCandidates up to 200 to inspect the full candidate list.");
1178
+ }
1179
+ else {
1180
+ warnings.push("Raise maxCandidates up to 200 to inspect the full candidate list, or use disambiguation.ownerHint to narrow the search.");
1181
+ }
990
1182
  }
991
1183
  const status = candidates.length === 0 ? "not_found" : candidates.length === 1 ? "resolved" : "ambiguous";
992
1184
  const ambiguityReasons = candidates.length > 1
993
1185
  ? inferAmbiguityReasons(candidates, pathUsesSource(graph.pairs, path, "mojang-client-mappings"))
994
1186
  : undefined;
995
- return {
1187
+ const output = {
996
1188
  querySymbol,
997
1189
  mappingContext,
998
1190
  resolved: status === "resolved",
@@ -1005,6 +1197,9 @@ export class MappingService {
1005
1197
  provenance: this.provenanceForPath(graph, path),
1006
1198
  ambiguityReasons
1007
1199
  };
1200
+ this.resolutionCache.set(cacheKey, { result: output, cachedAt: Date.now() });
1201
+ this.trimResolutionCache();
1202
+ return output;
1008
1203
  }
1009
1204
  async ensureMappingAvailable(input) {
1010
1205
  const version = input.version.trim();
@@ -1037,7 +1232,7 @@ export class MappingService {
1037
1232
  warnings: []
1038
1233
  };
1039
1234
  }
1040
- const graph = await this.loadGraph(version, priority, requiresOnlyObfuscatedMojangGraph(sourceMapping, targetMapping) ? "obfuscated-mojang-only" : "full");
1235
+ const graph = await this.loadGraph(version, priority, requiresOnlyObfuscatedMojangGraph(sourceMapping, targetMapping) ? "obfuscated-mojang-only" : "full", input.projectPath);
1041
1236
  const path = namespacePath(graph, sourceMapping, targetMapping);
1042
1237
  if (!path) {
1043
1238
  throw createError({
@@ -1121,7 +1316,7 @@ export class MappingService {
1121
1316
  warnings: []
1122
1317
  };
1123
1318
  }
1124
- const graph = await this.loadGraph(version, priority, requiresOnlyObfuscatedMojangGraph(sourceMapping, targetMapping) ? "obfuscated-mojang-only" : "full");
1319
+ const graph = await this.loadGraph(version, priority, requiresOnlyObfuscatedMojangGraph(sourceMapping, targetMapping) ? "obfuscated-mojang-only" : "full", input.projectPath);
1125
1320
  const path = namespacePath(graph, sourceMapping, targetMapping);
1126
1321
  if (!path) {
1127
1322
  return {
@@ -1137,12 +1332,16 @@ export class MappingService {
1137
1332
  };
1138
1333
  }
1139
1334
  const warnings = [];
1335
+ const descriptorProjection = this.projectMethodDescriptorToTarget(graph, path, descriptor);
1336
+ const projectedDescriptor = descriptorProjection.complete ? descriptorProjection.descriptor : undefined;
1140
1337
  const rawCandidates = this
1141
1338
  .mapCandidatesAlongPath(graph, path, queryRecord)
1142
- .filter((candidate) => candidate.kind === "method");
1339
+ .filter((candidate) => candidate.kind === "method")
1340
+ .map((candidate) => projectLookupCandidateDescriptor(candidate, descriptor, projectedDescriptor));
1143
1341
  const candidates = rawCandidates.map(toResolutionCandidate);
1144
1342
  const limitedCandidates = limitResolutionCandidates(candidates, input.maxCandidates);
1145
- const strictCandidates = rawCandidates.filter((candidate) => candidate.descriptor === descriptor);
1343
+ const strictDescriptor = projectedDescriptor ?? descriptor;
1344
+ const strictCandidates = rawCandidates.filter((candidate) => candidate.descriptor === strictDescriptor);
1146
1345
  if (strictCandidates.length === 1) {
1147
1346
  const resolved = toResolutionCandidate(strictCandidates[0]);
1148
1347
  return {
@@ -1160,6 +1359,9 @@ export class MappingService {
1160
1359
  }
1161
1360
  if (strictCandidates.length > 1) {
1162
1361
  warnings.push("Exact method mapping is ambiguous for owner+method+descriptor.");
1362
+ if (limitedCandidates.candidatesTruncated) {
1363
+ warnings.push("Raise maxCandidates up to 200 to inspect the full candidate list, or narrow the lookup via find-mapping disambiguation hints.");
1364
+ }
1163
1365
  return {
1164
1366
  querySymbol,
1165
1367
  mappingContext,
@@ -1172,8 +1374,10 @@ export class MappingService {
1172
1374
  provenance: this.provenanceForPath(graph, path)
1173
1375
  };
1174
1376
  }
1175
- if (pathUsesSource(graph.pairs, path, "mojang-client-mappings")) {
1176
- warnings.push("Method descriptor could not be preserved through mojang-client-mappings and exact resolution is unavailable.");
1377
+ if (descriptorProjection.hadClassReferences && !descriptorProjection.complete) {
1378
+ warnings.push(pathUsesSource(graph.pairs, path, "mojang-client-mappings")
1379
+ ? "Method descriptor could not be preserved through mojang-client-mappings and exact resolution is unavailable."
1380
+ : "Method descriptor could not be fully remapped across the mapping path and exact resolution is unavailable.");
1177
1381
  return {
1178
1382
  querySymbol,
1179
1383
  mappingContext,
@@ -1460,6 +1664,9 @@ export class MappingService {
1460
1664
  const buildOutput = (querySymbol, matched, status) => {
1461
1665
  const candidates = matched.map((record) => toResolutionCandidate(toLookupCandidate(record)));
1462
1666
  const limitedCandidates = limitResolutionCandidates(candidates, input.maxCandidates);
1667
+ if (status === "ambiguous" && limitedCandidates.candidatesTruncated) {
1668
+ warnings.push("Raise maxCandidates up to 200 to inspect the full candidate list.");
1669
+ }
1463
1670
  return {
1464
1671
  querySymbol,
1465
1672
  mappingContext,
@@ -1502,11 +1709,40 @@ export class MappingService {
1502
1709
  if (input.signatureMode === "name-only") {
1503
1710
  const status = methodCandidates.length === 1 ? "resolved" : methodCandidates.length > 1 ? "ambiguous" : "not_found";
1504
1711
  if (status === "ambiguous") {
1505
- warnings.push(`Multiple method overloads matched name "${queryRecord.name}" in owner "${queryRecord.owner}". Provide descriptor for exact match.`);
1712
+ // name-only discards any supplied descriptor, so telling the caller to "provide
1713
+ // descriptor" would not disambiguate — they need to switch to signatureMode="exact".
1714
+ warnings.push(`Multiple method overloads matched name "${queryRecord.name}" in owner "${queryRecord.owner}". Retry with signatureMode="exact" plus a JVM descriptor to pick one overload.`);
1506
1715
  }
1507
1716
  return buildOutput(querySymbol, methodCandidates, status);
1508
1717
  }
1509
- const descriptorMatched = methodCandidates.filter((record) => record.descriptor === queryRecord.descriptor);
1718
+ // Tiny parsing stores a single descriptor per method entry (typically in the obfuscated
1719
+ // namespace) and copies it into every namespace at load time. That means a descriptor
1720
+ // supplied in `sourceMapping` coordinates will not string-compare equal to the record
1721
+ // for any method whose descriptor references remapped Minecraft classes. Project the
1722
+ // caller's descriptor to obfuscated coordinates first so class references line up with
1723
+ // the stored record descriptors. The projection is accepted even when
1724
+ // `projection.complete` is false: `projectMethodDescriptorToTarget` leaves every
1725
+ // unresolvable `L...;` reference unchanged (JDK/external classes like
1726
+ // `Ljava/lang/String;` are never in the mapping graph and pass through by design), so a
1727
+ // partial projection still aligns the Minecraft class refs with the stored descriptor
1728
+ // form while leaving external class refs identical to the user input. Falling back to
1729
+ // verbatim comparison on `complete === false` would send mixed descriptors like
1730
+ // `(Lnet/minecraft/world/item/ItemStack;Ljava/lang/String;)V` down the raw-compare path
1731
+ // and produce false negatives in the most common lookup shape. When no class references
1732
+ // exist at all (primitives-only descriptors such as `(I)V`) the projector marks
1733
+ // `hadClassReferences === false` and we simply reuse the original descriptor.
1734
+ const queryDescriptor = queryRecord.descriptor;
1735
+ let effectiveDescriptor = queryDescriptor;
1736
+ if (sourceMapping !== "obfuscated") {
1737
+ const projectionPath = namespacePath(graph, sourceMapping, "obfuscated");
1738
+ if (projectionPath) {
1739
+ const projection = this.projectMethodDescriptorToTarget(graph, projectionPath, queryDescriptor);
1740
+ if (projection.hadClassReferences) {
1741
+ effectiveDescriptor = projection.descriptor;
1742
+ }
1743
+ }
1744
+ }
1745
+ const descriptorMatched = methodCandidates.filter((record) => record.descriptor === effectiveDescriptor || record.descriptor === queryDescriptor);
1510
1746
  if (descriptorMatched.length === 1) {
1511
1747
  return buildOutput(querySymbol, descriptorMatched, "resolved");
1512
1748
  }
@@ -1624,6 +1860,33 @@ export class MappingService {
1624
1860
  descriptor: item.record.descriptor
1625
1861
  }));
1626
1862
  }
1863
+ projectMethodDescriptorToTarget(graph, path, descriptor) {
1864
+ let hadClassReferences = false;
1865
+ let complete = true;
1866
+ const classProjectionCache = new Map();
1867
+ const projectedDescriptor = descriptor.replace(/L([^;]+);/g, (fullMatch, internalName) => {
1868
+ hadClassReferences = true;
1869
+ const cached = classProjectionCache.get(internalName);
1870
+ if (cached) {
1871
+ return `L${cached};`;
1872
+ }
1873
+ const projectedClassCandidates = this
1874
+ .mapCandidatesAlongPath(graph, path, createClassSymbolRecord(internalName.replace(/\//g, ".")))
1875
+ .filter((candidate) => candidate.kind === "class");
1876
+ if (projectedClassCandidates.length !== 1) {
1877
+ complete = false;
1878
+ return fullMatch;
1879
+ }
1880
+ const projectedInternalName = projectedClassCandidates[0].symbol.replace(/\./g, "/");
1881
+ classProjectionCache.set(internalName, projectedInternalName);
1882
+ return `L${projectedInternalName};`;
1883
+ });
1884
+ return {
1885
+ descriptor: projectedDescriptor,
1886
+ hadClassReferences,
1887
+ complete
1888
+ };
1889
+ }
1627
1890
  provenanceForPath(graph, path) {
1628
1891
  if (path.length <= 1) {
1629
1892
  return undefined;
@@ -1646,6 +1909,18 @@ export class MappingService {
1646
1909
  async checkMappingHealth(input) {
1647
1910
  const priority = mappingPriorityFromInput(this.config.mappingSourcePriority, input.sourcePriority);
1648
1911
  const degradations = [];
1912
+ if (isUnobfuscatedVersion(input.version)) {
1913
+ const requestFulfillable = input.requestedMapping === "obfuscated" || input.requestedMapping === "mojang";
1914
+ if (!requestFulfillable) {
1915
+ degradations.push(`Version ${input.version} is unobfuscated; ${input.requestedMapping} mappings are not applicable.`);
1916
+ }
1917
+ return {
1918
+ mojangMappingsAvailable: true,
1919
+ tinyMappingsAvailable: false,
1920
+ memberRemapAvailable: requestFulfillable,
1921
+ degradations
1922
+ };
1923
+ }
1649
1924
  let graph;
1650
1925
  try {
1651
1926
  graph = await this.loadGraph(input.version, priority, "full");
@@ -1692,8 +1967,9 @@ export class MappingService {
1692
1967
  degradations
1693
1968
  };
1694
1969
  }
1695
- async loadGraph(version, priority, mode) {
1696
- const cacheKey = `${version}|${priority}|${mode}`;
1970
+ async loadGraph(version, priority, mode, projectPath) {
1971
+ const effectiveProjectPath = effectiveLoomSearchProjectPath(projectPath);
1972
+ const cacheKey = `${version}|${priority}|${mode}|${effectiveProjectPath ?? ""}`;
1697
1973
  const cached = this.graphCache.get(cacheKey);
1698
1974
  if (cached) {
1699
1975
  this.graphCache.delete(cacheKey);
@@ -1704,7 +1980,7 @@ export class MappingService {
1704
1980
  if (existingLock) {
1705
1981
  return existingLock;
1706
1982
  }
1707
- const buildPromise = this.buildGraph(version, priority, mode);
1983
+ const buildPromise = this.buildGraph(version, priority, mode, effectiveProjectPath);
1708
1984
  this.buildLocks.set(cacheKey, buildPromise);
1709
1985
  try {
1710
1986
  const built = await buildPromise;
@@ -1716,7 +1992,7 @@ export class MappingService {
1716
1992
  this.buildLocks.delete(cacheKey);
1717
1993
  }
1718
1994
  }
1719
- async buildGraph(version, priority, mode) {
1995
+ async buildGraph(version, priority, mode, projectPath) {
1720
1996
  if (isUnobfuscatedVersion(version)) {
1721
1997
  return {
1722
1998
  version,
@@ -1749,7 +2025,7 @@ export class MappingService {
1749
2025
  const deferredTinyWarnings = [];
1750
2026
  for (const source of mappingSourceOrder(priority)) {
1751
2027
  const tinyLoad = source === "loom-cache"
1752
- ? await this.loadTinyPairsFromLoom(version)
2028
+ ? await this.loadTinyPairsFromLoom(version, projectPath)
1753
2029
  : await this.loadTinyPairsFromMaven(version);
1754
2030
  if (tinyLoad.pairs.size === 0) {
1755
2031
  deferredTinyWarnings.push(...tinyLoad.warnings);
@@ -1847,46 +2123,67 @@ export class MappingService {
1847
2123
  };
1848
2124
  }
1849
2125
  }
1850
- async loadTinyPairsFromLoom(version) {
1851
- const patterns = [".gradle/loom-cache/**/*.tiny", ".gradle/loom-cache/**/*.tinyv2"];
1852
- const candidates = fastGlob.sync(patterns, {
1853
- cwd: process.cwd(),
1854
- absolute: true,
1855
- onlyFiles: true
1856
- });
1857
- const byVersion = candidates
1858
- .filter((p) => p.replaceAll("\\", "/").includes(`/${version}/`))
1859
- .sort((left, right) => left.localeCompare(right));
1860
- if (byVersion.length === 0) {
1861
- return {
1862
- pairs: new Map(),
1863
- warnings: [`No Loom tiny mapping files matched version "${version}".`],
1864
- mappingArtifact: "loom-cache:none"
1865
- };
1866
- }
2126
+ async loadTinyPairsFromLoom(version, projectPath) {
2127
+ const searchRoots = buildVersionSourceSearchRoots(effectiveLoomSearchProjectPath(projectPath));
1867
2128
  const merged = new Map();
1868
- for (const path of byVersion) {
2129
+ const discoveredPaths = new Set();
2130
+ for (const root of searchRoots) {
2131
+ let discovered = [];
2132
+ const versionRoot = join(root, version);
1869
2133
  try {
1870
- const content = await readFile(path, "utf8");
1871
- const parsed = parseTinyMappings(content);
1872
- for (const [key, index] of parsed.entries()) {
1873
- const existing = merged.get(key);
1874
- if (!existing) {
1875
- merged.set(key, index);
1876
- }
1877
- else {
1878
- mergeDirectionIndexes(existing, index);
1879
- }
1880
- }
2134
+ discovered = existsSync(versionRoot)
2135
+ ? await fastGlob.glob(["**/*.tiny", "**/*.tinyv2"], {
2136
+ cwd: versionRoot,
2137
+ absolute: true,
2138
+ onlyFiles: true
2139
+ })
2140
+ : await fastGlob.glob([`${version.replace(GLOB_SPECIAL_CHARS, "\\$&")}/**/*.tiny`, `${version.replace(GLOB_SPECIAL_CHARS, "\\$&")}/**/*.tinyv2`], {
2141
+ cwd: root,
2142
+ absolute: true,
2143
+ onlyFiles: true
2144
+ });
1881
2145
  }
1882
2146
  catch {
1883
- // best effort: skip unreadable or invalid files
2147
+ continue;
1884
2148
  }
2149
+ const byVersion = discovered
2150
+ .filter((path) => path.replaceAll("\\", "/").includes(`/${version}/`))
2151
+ .sort((left, right) => left.localeCompare(right));
2152
+ if (byVersion.length === 0) {
2153
+ continue;
2154
+ }
2155
+ for (const path of byVersion) {
2156
+ discoveredPaths.add(path);
2157
+ try {
2158
+ const content = await readFile(path, "utf8");
2159
+ const parsed = parseTinyMappings(content);
2160
+ for (const [key, index] of parsed.entries()) {
2161
+ const existing = merged.get(key);
2162
+ if (!existing) {
2163
+ merged.set(key, index);
2164
+ }
2165
+ else {
2166
+ mergeDirectionIndexes(existing, index);
2167
+ }
2168
+ }
2169
+ }
2170
+ catch {
2171
+ // best effort: skip unreadable or invalid files
2172
+ }
2173
+ }
2174
+ }
2175
+ const orderedPaths = [...discoveredPaths].sort((left, right) => left.localeCompare(right));
2176
+ if (orderedPaths.length > 0) {
2177
+ return {
2178
+ pairs: merged,
2179
+ warnings: [],
2180
+ mappingArtifact: orderedPaths[0]
2181
+ };
1885
2182
  }
1886
2183
  return {
1887
- pairs: merged,
1888
- warnings: [],
1889
- mappingArtifact: byVersion[0]
2184
+ pairs: new Map(),
2185
+ warnings: [`No Loom tiny mapping files matched version "${version}".`],
2186
+ mappingArtifact: "loom-cache:none"
1890
2187
  };
1891
2188
  }
1892
2189
  async loadTinyPairsFromMaven(version) {
@@ -1942,15 +2239,11 @@ export class MappingService {
1942
2239
  };
1943
2240
  }
1944
2241
  async parseTinyFromJar(jarPath) {
1945
- const entries = await listJarEntries(jarPath);
1946
- const tinyEntries = entries
1947
- .filter((entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"))
1948
- .sort((left, right) => left.localeCompare(right));
2242
+ const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true })).sort((left, right) => left.filePath.localeCompare(right.filePath));
1949
2243
  const merged = new Map();
1950
2244
  for (const entry of tinyEntries) {
1951
2245
  try {
1952
- const text = await readJarEntryAsUtf8(jarPath, entry);
1953
- const parsed = parseTinyMappings(text);
2246
+ const parsed = parseTinyMappings(entry.content);
1954
2247
  for (const [key, index] of parsed.entries()) {
1955
2248
  const existing = merged.get(key);
1956
2249
  if (!existing) {
@@ -2002,14 +2295,59 @@ export class MappingService {
2002
2295
  this.graphCache.delete(oldestKey);
2003
2296
  }
2004
2297
  }
2298
+ // Note: in-flight buildLocks may re-populate graphCache after release.
2299
+ // Resolution cache entries created by concurrent findMapping() calls may also
2300
+ // survive this invalidation. Both are bounded by TTL (5 min) and will expire
2301
+ // naturally. A full epoch-based invalidation would add complexity for a rare
2302
+ // user-initiated operation (manage-cache).
2005
2303
  releaseGraphCacheEntry(version, sourcePriority) {
2006
2304
  const normalizedVersion = version.trim();
2007
2305
  if (!normalizedVersion) {
2008
2306
  return;
2009
2307
  }
2010
2308
  const priority = mappingPriorityFromInput(this.config.mappingSourcePriority, sourcePriority);
2011
- this.graphCache.delete(`${normalizedVersion}|${priority}|full`);
2012
- this.graphCache.delete(`${normalizedVersion}|${priority}|obfuscated-mojang-only`);
2309
+ const prefix = `${normalizedVersion}|${priority}|`;
2310
+ for (const key of this.graphCache.keys()) {
2311
+ if (key.startsWith(prefix)) {
2312
+ this.graphCache.delete(key);
2313
+ }
2314
+ }
2315
+ const resolutionPrefix = `${normalizedVersion}\0`;
2316
+ for (const key of this.resolutionCache.keys()) {
2317
+ if (key.startsWith(resolutionPrefix)) {
2318
+ this.resolutionCache.delete(key);
2319
+ }
2320
+ }
2321
+ }
2322
+ buildResolutionCacheKey(version, input, querySymbol, effectiveSignatureMode) {
2323
+ return [
2324
+ version,
2325
+ input.kind,
2326
+ querySymbol.symbol,
2327
+ querySymbol.descriptor ?? "",
2328
+ input.sourceMapping,
2329
+ input.targetMapping,
2330
+ input.sourcePriority ?? "",
2331
+ effectiveLoomSearchProjectPath(input.projectPath) ?? "",
2332
+ effectiveSignatureMode,
2333
+ String(input.maxCandidates ?? ""),
2334
+ JSON.stringify(input.disambiguation ?? "")
2335
+ ].join("\0");
2336
+ }
2337
+ trimResolutionCache() {
2338
+ if (this.resolutionCache.size <= MappingService.RESOLUTION_CACHE_MAX)
2339
+ return;
2340
+ const now = Date.now();
2341
+ for (const [key, entry] of this.resolutionCache) {
2342
+ if (now - entry.cachedAt > MappingService.RESOLUTION_CACHE_TTL_MS) {
2343
+ this.resolutionCache.delete(key);
2344
+ }
2345
+ }
2346
+ while (this.resolutionCache.size > MappingService.RESOLUTION_CACHE_MAX) {
2347
+ const firstKey = this.resolutionCache.keys().next().value;
2348
+ if (firstKey !== undefined)
2349
+ this.resolutionCache.delete(firstKey);
2350
+ }
2013
2351
  }
2014
2352
  }
2015
2353
  // ---------------------------------------------------------------------------
@@ -2039,14 +2377,13 @@ async function fetchYarnCoordinatesStandalone(version, fetchFn = globalThis.fetc
2039
2377
  }
2040
2378
  }
2041
2379
  async function extractTinyFromJar(jarPath, outputPath) {
2042
- const entries = await listJarEntries(jarPath);
2043
- const tinyEntry = entries.find((entry) => entry === "mappings/mappings.tiny" || entry.toLowerCase().endsWith(".tiny"));
2380
+ const matchedEntries = await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry === "mappings/mappings.tiny" || entry.toLowerCase().endsWith(".tiny"), { maxEntries: 1 });
2381
+ const tinyEntry = matchedEntries[0];
2044
2382
  if (!tinyEntry) {
2045
2383
  return false;
2046
2384
  }
2047
- const content = await readJarEntryAsUtf8(jarPath, tinyEntry);
2048
2385
  await mkdir(dirname(outputPath), { recursive: true });
2049
- await writeFile(outputPath, content, "utf8");
2386
+ await writeFile(outputPath, tinyEntry.content, "utf8");
2050
2387
  return true;
2051
2388
  }
2052
2389
  /**