@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
@@ -1,31 +1,37 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync } from "node:fs";
3
- import { readFile, writeFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
2
+ import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
3
+ import { access, mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
+ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
6
5
  import fastGlob from "fast-glob";
6
+ import { mapWithConcurrencyLimit } from "./concurrency.js";
7
7
  import { createError, ERROR_CODES, isAppError } from "./errors.js";
8
- import { loadConfig } from "./config.js";
8
+ import { buildArtifactAlias, loadConfig } from "./config.js";
9
9
  import { decompileBinaryJar } from "./decompiler/vineflower.js";
10
10
  import { resolveVineflowerJar } from "./vineflower-resolver.js";
11
+ import { remapJar } from "./tiny-remapper-service.js";
12
+ import { resolveTinyRemapperJar } from "./tiny-remapper-resolver.js";
13
+ import { resolveMojangTinyFile } from "./mojang-tiny-mapping-service.js";
11
14
  import { parseCoordinate } from "./maven-resolver.js";
12
- import { MinecraftExplorerService } from "./minecraft-explorer-service.js";
15
+ import { MinecraftExplorerService, modifierPrefix, parseFieldType, parseMethodDescriptor } from "./minecraft-explorer-service.js";
13
16
  import { parseMixinSource } from "./mixin-parser.js";
14
17
  import { parseAccessWidener } from "./access-widener-parser.js";
15
- import { validateParsedMixin, refreshMixinValidationOutcome, validateParsedAccessWidener } from "./mixin-validator.js";
18
+ import { parseAccessTransformer } from "./access-transformer-parser.js";
19
+ import { validateParsedMixin, refreshMixinValidationOutcome, validateParsedAccessWidener, validateParsedAccessTransformer } from "./mixin-validator.js";
16
20
  import { resolveSourceTarget as resolveSourceTargetInternal } from "./source-resolver.js";
17
21
  import { applyMappingPipeline } from "./mapping-pipeline-service.js";
18
22
  import { MappingService } from "./mapping-service.js";
19
23
  import { extractSymbolsFromSource } from "./symbols/symbol-extractor.js";
20
- import { iterateJavaEntriesAsUtf8, listJavaEntries } from "./source-jar-reader.js";
24
+ import { detectFabricLikeInputNamespace, iterateJavaEntriesAsUtf8, listJavaEntries } from "./source-jar-reader.js";
21
25
  import { openDatabase } from "./storage/db.js";
22
26
  import { ArtifactsRepo } from "./storage/artifacts-repo.js";
23
27
  import { FilesRepo } from "./storage/files-repo.js";
24
28
  import { IndexMetaRepo } from "./storage/index-meta-repo.js";
25
29
  import { SymbolsRepo } from "./storage/symbols-repo.js";
26
30
  import { RuntimeMetrics } from "./observability.js";
31
+ import { LruList } from "./lru-list.js";
27
32
  import { log } from "./logger.js";
28
33
  import { normalizePathForHost } from "./path-converter.js";
34
+ import { buildLoaderRuntimeSearchRoots, buildVersionSourceSearchRoots, normalizeOptionalProjectPath } from "./gradle-paths.js";
29
35
  import { createSearchHitAccumulator, decodeSearchCursor, encodeSearchCursor } from "./search-hit-accumulator.js";
30
36
  import { WorkspaceMappingService } from "./workspace-mapping-service.js";
31
37
  import { VersionService, isUnobfuscatedVersion } from "./version-service.js";
@@ -146,6 +152,26 @@ function sameMixinValidationProvenance(left, right) {
146
152
  sameScopeFallback(left.scopeFallback, right.scopeFallback) &&
147
153
  sameResolutionTrace(left.resolutionTrace, right.resolutionTrace));
148
154
  }
155
+ function annotateValidateMixinError(err, stage) {
156
+ if (isAppError(err)) {
157
+ const existing = (err.details ?? {});
158
+ if (typeof existing.failedStage === "string") {
159
+ // A nested call already tagged this error (e.g. input-validation). Preserve it.
160
+ return err;
161
+ }
162
+ return createError({
163
+ code: err.code,
164
+ message: err.message,
165
+ details: { ...existing, failedStage: stage }
166
+ });
167
+ }
168
+ const message = err instanceof Error ? err.message : String(err);
169
+ return createError({
170
+ code: ERROR_CODES.INTERNAL,
171
+ message: `validate-mixin failed during stage "${stage}": ${message}`,
172
+ details: { failedStage: stage }
173
+ });
174
+ }
149
175
  const INDEX_SCHEMA_VERSION = 1;
150
176
  const SYMBOL_KINDS = ["class", "interface", "enum", "record", "method", "field"];
151
177
  function isSymbolKind(value) {
@@ -178,6 +204,30 @@ function hasExactVersionToken(path, version) {
178
204
  ?? rememberCachedRegex(VERSION_TOKEN_REGEX_CACHE, normalizedVersion, new RegExp(`(^|[^0-9a-z])${escapeRegexLiteral(normalizedVersion)}([^0-9a-z]|$)`, "i"));
179
205
  return pattern.test(normalizedPath);
180
206
  }
207
+ function inferMergedRuntimeNamespaceHint(path) {
208
+ const normalizedPath = normalizePathStyle(path).toLowerCase();
209
+ if (normalizedPath.includes("merged-intermediary-v2") ||
210
+ normalizedPath.includes("merged-intermediary")) {
211
+ return "intermediary";
212
+ }
213
+ if (normalizedPath.includes("minecraft-merged-mojang") ||
214
+ normalizedPath.includes("merged-mojang")) {
215
+ return "mojang";
216
+ }
217
+ if (normalizedPath.includes("merged-named")) {
218
+ return "named";
219
+ }
220
+ return undefined;
221
+ }
222
+ function runtimeJarNamespaceHintScore(hint) {
223
+ if (hint === "intermediary" || hint === "mojang") {
224
+ return 8_000;
225
+ }
226
+ if (hint === "named") {
227
+ return 1_000;
228
+ }
229
+ return 0;
230
+ }
181
231
  function looksLikeDeobfuscatedClassName(value) {
182
232
  const trimmed = value.trim();
183
233
  if (!trimmed) {
@@ -204,17 +254,6 @@ function buildResolveArtifactParams(target, extra = {}) {
204
254
  ...extra
205
255
  };
206
256
  }
207
- function normalizeOptionalProjectPath(projectPath) {
208
- if (!projectPath) {
209
- return undefined;
210
- }
211
- const trimmed = projectPath.trim();
212
- if (!trimmed) {
213
- return undefined;
214
- }
215
- const normalized = normalizePathForHost(trimmed, undefined, "projectPath");
216
- return isAbsolute(normalized) ? normalized : resolvePath(process.cwd(), normalized);
217
- }
218
257
  function looksLikeClassSegment(name) {
219
258
  const trimmed = name.trim();
220
259
  return /^[A-Z_$]/.test(trimmed);
@@ -227,29 +266,6 @@ function looksLikeJvmMethodDescriptor(descriptor) {
227
266
  const closing = trimmed.indexOf(")");
228
267
  return closing > 0 && closing < trimmed.length - 1;
229
268
  }
230
- function resolveGradleUserHomePath() {
231
- const configured = process.env.GRADLE_USER_HOME?.trim();
232
- if (!configured) {
233
- return resolvePath(homedir(), ".gradle");
234
- }
235
- const normalized = normalizePathForHost(configured, undefined, "GRADLE_USER_HOME");
236
- return isAbsolute(normalized) ? normalized : resolvePath(process.cwd(), normalized);
237
- }
238
- function buildVersionSourceSearchRoots(projectPath) {
239
- const roots = new Set();
240
- if (projectPath) {
241
- roots.add(resolvePath(projectPath, ".gradle", "loom-cache"));
242
- roots.add(resolvePath(projectPath, ".gradle-user", "caches", "fabric-loom"));
243
- roots.add(resolvePath(projectPath, ".gradle", "caches", "fabric-loom"));
244
- const projectParent = dirname(projectPath);
245
- roots.add(resolvePath(projectParent, ".gradle-user-home", "loom-cache"));
246
- roots.add(resolvePath(projectParent, ".gradle-user-home", "caches", "fabric-loom"));
247
- }
248
- const homeGradle = resolveGradleUserHomePath();
249
- roots.add(resolvePath(homeGradle, "loom-cache"));
250
- roots.add(resolvePath(homeGradle, "caches", "fabric-loom"));
251
- return [...roots];
252
- }
253
269
  function looksLikeMinecraftSourceArtifact(path, hasMinecraftNamespace) {
254
270
  if (hasMinecraftNamespace) {
255
271
  return true;
@@ -328,27 +344,6 @@ function normalizeOptionalString(value) {
328
344
  const trimmed = value.trim();
329
345
  return trimmed ? trimmed : undefined;
330
346
  }
331
- async function mapWithConcurrencyLimit(items, limit, mapper) {
332
- if (items.length === 0) {
333
- return [];
334
- }
335
- const results = new Array(items.length);
336
- let nextIndex = 0;
337
- const workerCount = Math.max(1, Math.min(Math.trunc(limit), items.length));
338
- await Promise.all(Array.from({ length: workerCount }, async () => {
339
- while (true) {
340
- // Safe in Node's single-threaded event loop because no await occurs between
341
- // reading and incrementing nextIndex inside this synchronous dispatch section.
342
- const currentIndex = nextIndex;
343
- nextIndex += 1;
344
- if (currentIndex >= items.length) {
345
- return;
346
- }
347
- results[currentIndex] = await mapper(items[currentIndex], currentIndex);
348
- }
349
- }));
350
- return results;
351
- }
352
347
  function normalizeStrictPositiveInt(value, field) {
353
348
  if (value == null) {
354
349
  return undefined;
@@ -386,6 +381,15 @@ const MIXIN_PROJECT_DISCOVERY_IGNORES = [
386
381
  "**/out/**",
387
382
  "**/node_modules/**"
388
383
  ];
384
+ async function pathExists(filePath) {
385
+ try {
386
+ await access(filePath);
387
+ return true;
388
+ }
389
+ catch {
390
+ return false;
391
+ }
392
+ }
389
393
  function normalizeMapping(mapping) {
390
394
  if (mapping == null) {
391
395
  return "obfuscated";
@@ -422,6 +426,19 @@ function normalizeAccessWidenerNamespace(namespace) {
422
426
  }
423
427
  return undefined;
424
428
  }
429
+ function normalizeAccessTransformerNamespace(namespace) {
430
+ const normalized = namespace?.trim().toLowerCase();
431
+ if (!normalized) {
432
+ return undefined;
433
+ }
434
+ if (normalized === "srg" || normalized === "mojang" || normalized === "obfuscated") {
435
+ return normalized;
436
+ }
437
+ return undefined;
438
+ }
439
+ function isSourceMappingNamespace(namespace) {
440
+ return namespace === "obfuscated" || namespace === "mojang" || namespace === "intermediary" || namespace === "yarn";
441
+ }
425
442
  function normalizeMemberAccess(access) {
426
443
  if (access == null) {
427
444
  return "public";
@@ -775,11 +792,12 @@ export class SourceService {
775
792
  versionDiffService;
776
793
  modDecompileService;
777
794
  modSearchService;
778
- cacheMetricsState = {
779
- entries: 0,
780
- totalContentBytes: 0,
781
- lru: []
782
- };
795
+ lru = new LruList();
796
+ cacheTotalContentBytes = 0;
797
+ remappedJarBytes = new Map();
798
+ /** In-flight binary-remap jobs keyed by remapped jar path so concurrent
799
+ * resolveArtifact calls for the same artifactId share a single tiny-remapper run. */
800
+ inflightRemaps = new Map();
783
801
  constructor(explicitConfig, metrics = new RuntimeMetrics()) {
784
802
  this.config = explicitConfig ?? loadConfig();
785
803
  this.metrics = metrics;
@@ -809,7 +827,7 @@ export class SourceService {
809
827
  searchedPaths.push(root);
810
828
  let discovered = [];
811
829
  try {
812
- discovered = fastGlob.sync("**/*sources.jar", {
830
+ discovered = await fastGlob.glob("**/*sources.jar", {
813
831
  cwd: root,
814
832
  absolute: true,
815
833
  onlyFiles: true
@@ -873,6 +891,371 @@ export class SourceService {
873
891
  selectedHasMinecraftNamespace: selected?.hasMinecraftNamespace
874
892
  };
875
893
  }
894
+ async discoverAccessWidenerRuntimeCandidates(input) {
895
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
896
+ const normalizedProjectPathLower = normalizedProjectPath
897
+ ? normalizePathStyle(normalizedProjectPath).toLowerCase()
898
+ : undefined;
899
+ const searchRoots = buildVersionSourceSearchRoots(normalizedProjectPath);
900
+ const searchedPaths = [];
901
+ const candidates = [];
902
+ const seen = new Set();
903
+ for (const root of searchRoots) {
904
+ searchedPaths.push(root);
905
+ let discovered = [];
906
+ try {
907
+ discovered = await fastGlob.glob(["**/*minecraft*.jar", "**/*merged*.jar"], {
908
+ cwd: root,
909
+ absolute: true,
910
+ onlyFiles: true,
911
+ ignore: ["**/*sources.jar", "**/node_modules/**", "**/.git/**", "**/build/**", "**/out/**"]
912
+ });
913
+ }
914
+ catch {
915
+ continue;
916
+ }
917
+ for (const candidatePath of discovered) {
918
+ const normalizedPath = normalizePathStyle(candidatePath);
919
+ if (seen.has(normalizedPath)) {
920
+ continue;
921
+ }
922
+ seen.add(normalizedPath);
923
+ const lower = normalizedPath.toLowerCase();
924
+ if (!lower.includes("minecraft")) {
925
+ continue;
926
+ }
927
+ const exactVersionMatch = hasExactVersionToken(normalizedPath, input.version);
928
+ const looksMerged = lower.includes("minecraft-merged") || lower.includes("/merged/") || lower.includes("-merged");
929
+ const namespaceHint = inferMergedRuntimeNamespaceHint(normalizedPath);
930
+ const appliedScope = looksMerged
931
+ ? "merged"
932
+ : input.requestedScope === "loader"
933
+ ? "merged"
934
+ : input.requestedScope;
935
+ const score = (exactVersionMatch ? 5_000 : 0) +
936
+ (looksMerged ? 4_000 : 0) +
937
+ runtimeJarNamespaceHintScore(namespaceHint) +
938
+ (normalizedProjectPathLower && lower.startsWith(normalizedProjectPathLower) ? 2_000 : 0) +
939
+ (lower.includes("loom-cache") || lower.includes("/caches/fabric-loom/") ? 500 : 0) +
940
+ (lower.includes("minecraft-client") || lower.includes("client") ? 100 : 0);
941
+ candidates.push({
942
+ jarPath: normalizedPath,
943
+ score,
944
+ appliedScope,
945
+ origin: lower.includes("loom-cache") || lower.includes("/caches/fabric-loom/")
946
+ ? "loom-cache"
947
+ : "local-jar",
948
+ namespaceHint
949
+ });
950
+ }
951
+ }
952
+ candidates.sort((left, right) => {
953
+ if (right.score !== left.score) {
954
+ return right.score - left.score;
955
+ }
956
+ return left.jarPath.localeCompare(right.jarPath);
957
+ });
958
+ return {
959
+ searchedPaths,
960
+ candidateArtifacts: candidates.slice(0, 20).map((candidate) => candidate.jarPath),
961
+ selected: candidates[0]
962
+ };
963
+ }
964
+ async discoverAccessTransformerRuntimeCandidates(input) {
965
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
966
+ const normalizedProjectPathLower = normalizedProjectPath
967
+ ? normalizePathStyle(normalizedProjectPath).toLowerCase()
968
+ : undefined;
969
+ const searchRoots = buildLoaderRuntimeSearchRoots(normalizedProjectPath);
970
+ const searchedPaths = [];
971
+ const candidates = [];
972
+ const seen = new Set();
973
+ const globs = [
974
+ "**/*minecraft*.jar",
975
+ "**/*patched*.jar",
976
+ "**/*srg*.jar",
977
+ "**/*joined*.jar",
978
+ "**/*client-extra*.jar",
979
+ "**/*forge*.jar",
980
+ "**/*neoforge*.jar",
981
+ "**/*moddev*.jar",
982
+ "**/*neoform*.jar"
983
+ ];
984
+ for (const root of searchRoots) {
985
+ searchedPaths.push(root);
986
+ if (!(await pathExists(root))) {
987
+ continue;
988
+ }
989
+ let discovered = [];
990
+ try {
991
+ discovered = await fastGlob.glob(globs, {
992
+ cwd: root,
993
+ absolute: true,
994
+ onlyFiles: true,
995
+ ignore: ["**/*sources.jar", "**/node_modules/**", "**/.git/**", "**/out/**"]
996
+ });
997
+ }
998
+ catch {
999
+ continue;
1000
+ }
1001
+ for (const candidatePath of discovered) {
1002
+ const normalizedPath = normalizePathStyle(candidatePath);
1003
+ if (seen.has(normalizedPath)) {
1004
+ continue;
1005
+ }
1006
+ seen.add(normalizedPath);
1007
+ const lower = normalizedPath.toLowerCase();
1008
+ if (!hasExactVersionToken(normalizedPath, input.version)) {
1009
+ continue;
1010
+ }
1011
+ const looksMerged = lower.includes("merged");
1012
+ const looksSrg = lower.includes("srg");
1013
+ const looksForge = lower.includes("forge");
1014
+ const looksNeoForge = lower.includes("neoforge") || lower.includes("moddev") || lower.includes("neoform");
1015
+ const looksPatchedRuntime = lower.includes("patched") || lower.includes("client-extra") || lower.includes("joined");
1016
+ const appliedScope = looksMerged
1017
+ ? "merged"
1018
+ : "loader";
1019
+ if (input.atNamespace === "srg" && !looksSrg) {
1020
+ continue;
1021
+ }
1022
+ if (input.loader === "forge" && !looksForge && !looksSrg && !looksPatchedRuntime) {
1023
+ continue;
1024
+ }
1025
+ if (input.loader === "neoforge" && !looksNeoForge && !looksPatchedRuntime && !lower.includes("minecraft")) {
1026
+ continue;
1027
+ }
1028
+ const score = 10_000 +
1029
+ (normalizedProjectPathLower && lower.startsWith(normalizedProjectPathLower) ? 4_000 : 0) +
1030
+ (looksPatchedRuntime ? 3_000 : 0) +
1031
+ (looksSrg ? 2_500 : 0) +
1032
+ (input.loader === "forge" && looksForge ? 1_500 : 0) +
1033
+ (input.loader === "neoforge" && looksNeoForge ? 1_500 : 0) +
1034
+ (input.requestedScope === appliedScope ? 1_000 : 0) +
1035
+ (looksMerged ? -500 : 0);
1036
+ candidates.push({
1037
+ jarPath: normalizedPath,
1038
+ score,
1039
+ appliedScope,
1040
+ origin: "local-jar"
1041
+ });
1042
+ }
1043
+ }
1044
+ candidates.sort((left, right) => {
1045
+ if (right.score !== left.score) {
1046
+ return right.score - left.score;
1047
+ }
1048
+ return left.jarPath.localeCompare(right.jarPath);
1049
+ });
1050
+ return {
1051
+ searchedPaths,
1052
+ candidateArtifacts: candidates.slice(0, 20).map((candidate) => candidate.jarPath),
1053
+ selected: candidates[0]
1054
+ };
1055
+ }
1056
+ async resolveAccessWidenerRuntimeArtifact(input) {
1057
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
1058
+ let version = input.version;
1059
+ if (input.preferProjectVersion && normalizedProjectPath) {
1060
+ const detected = await this.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
1061
+ version = detected ?? version;
1062
+ }
1063
+ const requestedScope = input.scope ?? (normalizedProjectPath ? "loader" : "vanilla");
1064
+ if (requestedScope === "vanilla") {
1065
+ const versionJar = await this.versionService.resolveVersionJar(version);
1066
+ return {
1067
+ version: versionJar.version,
1068
+ jarPath: versionJar.jarPath,
1069
+ requestedScope,
1070
+ appliedScope: "vanilla",
1071
+ requestedMapping: input.awNamespace,
1072
+ mappingApplied: "obfuscated",
1073
+ origin: "version-jar"
1074
+ };
1075
+ }
1076
+ const discovery = await this.discoverAccessWidenerRuntimeCandidates({
1077
+ version,
1078
+ projectPath: normalizedProjectPath,
1079
+ requestedScope
1080
+ });
1081
+ if (!discovery.selected) {
1082
+ throw createError({
1083
+ code: ERROR_CODES.CONTEXT_UNRESOLVED,
1084
+ message: "Could not resolve a runtime jar for Access Widener validation.",
1085
+ details: {
1086
+ version,
1087
+ requestedScope,
1088
+ projectPath: normalizedProjectPath,
1089
+ searchedPaths: discovery.searchedPaths,
1090
+ candidateArtifacts: discovery.candidateArtifacts,
1091
+ nextAction: "Provide projectPath for a Loom workspace with generated runtime jars, or run Gradle tasks that populate the Loom cache before retrying.",
1092
+ suggestedCall: {
1093
+ tool: "validate-access-widener",
1094
+ params: {
1095
+ version,
1096
+ scope: requestedScope,
1097
+ ...(normalizedProjectPath ? { projectPath: normalizedProjectPath } : {})
1098
+ }
1099
+ }
1100
+ }
1101
+ });
1102
+ }
1103
+ const appliedScope = discovery.selected.appliedScope;
1104
+ const scopeFallback = requestedScope !== appliedScope
1105
+ ? {
1106
+ requested: requestedScope,
1107
+ applied: appliedScope,
1108
+ reason: requestedScope === "loader"
1109
+ ? "Fabric loader runtime validation currently reuses the merged runtime jar."
1110
+ : "Selected runtime jar matched a nearby merged artifact."
1111
+ }
1112
+ : undefined;
1113
+ let detectedMapping;
1114
+ const notes = [];
1115
+ if (scopeFallback) {
1116
+ notes.push(scopeFallback.reason);
1117
+ }
1118
+ if (isUnobfuscatedVersion(version)) {
1119
+ detectedMapping = "obfuscated";
1120
+ }
1121
+ else if (discovery.selected.namespaceHint === "intermediary" ||
1122
+ discovery.selected.namespaceHint === "mojang") {
1123
+ detectedMapping = discovery.selected.namespaceHint;
1124
+ }
1125
+ else {
1126
+ const detection = await detectFabricLikeInputNamespace(discovery.selected.jarPath);
1127
+ detectedMapping = detection.fromNamespace;
1128
+ if (detection.warnings.length > 0) {
1129
+ notes.push(...detection.warnings);
1130
+ }
1131
+ }
1132
+ return {
1133
+ version,
1134
+ jarPath: discovery.selected.jarPath,
1135
+ requestedScope,
1136
+ appliedScope,
1137
+ requestedMapping: input.awNamespace,
1138
+ mappingApplied: detectedMapping,
1139
+ origin: discovery.selected.origin,
1140
+ resolutionNotes: notes.length > 0 ? notes : undefined,
1141
+ scopeFallback
1142
+ };
1143
+ }
1144
+ async resolveAccessTransformerNamespace(input) {
1145
+ const explicit = normalizeAccessTransformerNamespace(input.atNamespace);
1146
+ if (explicit) {
1147
+ return explicit;
1148
+ }
1149
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
1150
+ if (!normalizedProjectPath) {
1151
+ throw createError({
1152
+ code: ERROR_CODES.INVALID_INPUT,
1153
+ message: "atNamespace is required when projectPath is not provided.",
1154
+ details: {
1155
+ nextAction: "Pass atNamespace explicitly, or provide projectPath for a Forge/NeoForge workspace so the namespace can be inferred."
1156
+ }
1157
+ });
1158
+ }
1159
+ const loaderDetection = await this.workspaceMappingService.detectProjectLoader(normalizedProjectPath);
1160
+ if (loaderDetection.resolved && loaderDetection.loader === "forge") {
1161
+ return "srg";
1162
+ }
1163
+ if (loaderDetection.resolved && loaderDetection.loader === "neoforge") {
1164
+ return "mojang";
1165
+ }
1166
+ throw createError({
1167
+ code: ERROR_CODES.INVALID_INPUT,
1168
+ message: "Could not infer atNamespace from the workspace.",
1169
+ details: {
1170
+ projectPath: normalizedProjectPath,
1171
+ evidence: loaderDetection.evidence,
1172
+ warnings: loaderDetection.warnings,
1173
+ nextAction: "Pass atNamespace explicitly, or point projectPath at a Forge/NeoForge workspace."
1174
+ }
1175
+ });
1176
+ }
1177
+ async resolveAccessTransformerRuntimeArtifact(input) {
1178
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
1179
+ let version = input.version;
1180
+ if (input.preferProjectVersion && normalizedProjectPath) {
1181
+ const detected = await this.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
1182
+ version = detected ?? version;
1183
+ }
1184
+ const requestedScope = input.scope ?? (normalizedProjectPath ? "loader" : "vanilla");
1185
+ if (requestedScope === "vanilla") {
1186
+ if (input.atNamespace === "srg") {
1187
+ throw createError({
1188
+ code: ERROR_CODES.INVALID_INPUT,
1189
+ message: "atNamespace=srg requires projectPath and scope=loader so a Forge runtime jar can be resolved."
1190
+ });
1191
+ }
1192
+ const versionJar = await this.versionService.resolveVersionJar(version);
1193
+ return {
1194
+ version: versionJar.version,
1195
+ jarPath: versionJar.jarPath,
1196
+ requestedScope,
1197
+ appliedScope: "vanilla",
1198
+ requestedMapping: input.atNamespace,
1199
+ mappingApplied: "obfuscated",
1200
+ origin: "version-jar"
1201
+ };
1202
+ }
1203
+ const loaderDetection = normalizedProjectPath
1204
+ ? await this.workspaceMappingService.detectProjectLoader(normalizedProjectPath)
1205
+ : { resolved: false, loader: undefined, evidence: [], warnings: [] };
1206
+ const loader = loaderDetection.resolved ? loaderDetection.loader ?? "unknown" : "unknown";
1207
+ const discovery = await this.discoverAccessTransformerRuntimeCandidates({
1208
+ version,
1209
+ projectPath: normalizedProjectPath,
1210
+ requestedScope,
1211
+ atNamespace: input.atNamespace,
1212
+ loader
1213
+ });
1214
+ if (!discovery.selected) {
1215
+ throw createError({
1216
+ code: ERROR_CODES.CONTEXT_UNRESOLVED,
1217
+ message: "Could not resolve a runtime jar for Access Transformer validation.",
1218
+ details: {
1219
+ version,
1220
+ requestedScope,
1221
+ atNamespace: input.atNamespace,
1222
+ projectPath: normalizedProjectPath,
1223
+ searchedPaths: discovery.searchedPaths,
1224
+ candidateArtifacts: discovery.candidateArtifacts,
1225
+ loaderEvidence: loaderDetection.evidence,
1226
+ loaderWarnings: loaderDetection.warnings,
1227
+ nextAction: "Provide projectPath for a Forge/NeoForge workspace with generated runtime jars, or run the Gradle tasks that populate transformed runtime artifacts before retrying."
1228
+ }
1229
+ });
1230
+ }
1231
+ const selected = discovery.selected;
1232
+ const selectedLower = selected.jarPath.toLowerCase();
1233
+ const mappingApplied = input.atNamespace === "srg" || selectedLower.includes("srg")
1234
+ ? "srg"
1235
+ : loader === "neoforge" || selectedLower.includes("moddev") || selectedLower.includes("neoforge")
1236
+ ? "mojang"
1237
+ : "obfuscated";
1238
+ const scopeFallback = requestedScope !== selected.appliedScope
1239
+ ? {
1240
+ requested: requestedScope,
1241
+ applied: selected.appliedScope,
1242
+ reason: selected.appliedScope === "merged"
1243
+ ? "Resolved a nearby merged runtime jar because no transformed loader artifact was available."
1244
+ : "Resolved the closest transformed runtime artifact for validation."
1245
+ }
1246
+ : undefined;
1247
+ return {
1248
+ version,
1249
+ jarPath: selected.jarPath,
1250
+ requestedScope,
1251
+ appliedScope: selected.appliedScope,
1252
+ requestedMapping: input.atNamespace,
1253
+ mappingApplied,
1254
+ origin: selected.origin,
1255
+ resolutionNotes: scopeFallback ? [scopeFallback.reason] : undefined,
1256
+ scopeFallback
1257
+ };
1258
+ }
876
1259
  buildVersionSourceRecoveryCommand(projectPath) {
877
1260
  const normalizedProjectPath = normalizeOptionalProjectPath(projectPath);
878
1261
  const prefix = normalizedProjectPath
@@ -880,6 +1263,91 @@ export class SourceService {
880
1263
  : "";
881
1264
  return `${prefix}./gradlew genSources --no-daemon`;
882
1265
  }
1266
+ /**
1267
+ * Decide whether the upcoming resolveArtifact call may transparently remap a
1268
+ * binary-only artifact (obfuscated -> mojang) and decompile it. The gate
1269
+ * succeeds only when:
1270
+ * - the requested mapping is "mojang" on a still-obfuscated runtime
1271
+ * - tiny-remapper jar is downloadable / available locally
1272
+ * - the version's Mojang tiny mapping file can be produced
1273
+ * - checkMappingHealth reports mojang mappings are usable
1274
+ * On any failure the variant defaults to "pass" so the legacy
1275
+ * MAPPING_NOT_APPLIED fallback (or the existing source-backed flow) keeps
1276
+ * its existing artifactId hash.
1277
+ */
1278
+ async computeBinaryRemapGate(input) {
1279
+ const baseline = {
1280
+ allowBinaryRemap: false,
1281
+ mappingVariant: "pass",
1282
+ warnings: []
1283
+ };
1284
+ if (input.requestedMapping !== "mojang" ||
1285
+ input.runtimeNamesUnobfuscated ||
1286
+ !input.version) {
1287
+ return baseline;
1288
+ }
1289
+ // The Mojang tiny mapping file is only valid for vanilla Minecraft client/server jars.
1290
+ // For coordinate / jar inputs the resolver cannot prove the artifact identity, so
1291
+ // applying Minecraft mappings to an unrelated library/mod jar would produce a
1292
+ // "successful" but corrupted artifact instead of the safe MAPPING_NOT_APPLIED reject.
1293
+ // Only target.kind="version" goes through versionService.resolveVersionJar with a
1294
+ // verified Minecraft download URL, so we restrict the gate to that input shape.
1295
+ if (input.targetKind !== "version") {
1296
+ return baseline;
1297
+ }
1298
+ let tinyRemapperJarPath;
1299
+ try {
1300
+ tinyRemapperJarPath = await resolveTinyRemapperJar(this.config.cacheDir, this.config.tinyRemapperJarPath);
1301
+ }
1302
+ catch (caughtError) {
1303
+ log("warn", "binary-remap.gate.tiny-remapper-unavailable", {
1304
+ version: input.version,
1305
+ error: caughtError instanceof Error ? caughtError.message : String(caughtError)
1306
+ });
1307
+ return baseline;
1308
+ }
1309
+ let mojangTinyFilePath;
1310
+ try {
1311
+ const mojangTiny = await resolveMojangTinyFile(input.version, this.config);
1312
+ mojangTinyFilePath = mojangTiny.path;
1313
+ if (mojangTiny.warnings.length > 0) {
1314
+ baseline.warnings.push(...mojangTiny.warnings);
1315
+ }
1316
+ }
1317
+ catch (caughtError) {
1318
+ log("warn", "binary-remap.gate.mojang-tiny-unavailable", {
1319
+ version: input.version,
1320
+ error: caughtError instanceof Error ? caughtError.message : String(caughtError)
1321
+ });
1322
+ return baseline;
1323
+ }
1324
+ let mojangAvailable = false;
1325
+ try {
1326
+ const health = await this.mappingService.checkMappingHealth({
1327
+ version: input.version,
1328
+ requestedMapping: "mojang",
1329
+ sourcePriority: input.sourcePriority
1330
+ });
1331
+ mojangAvailable = health.mojangMappingsAvailable;
1332
+ }
1333
+ catch (caughtError) {
1334
+ log("warn", "binary-remap.gate.health-check-failed", {
1335
+ version: input.version,
1336
+ error: caughtError instanceof Error ? caughtError.message : String(caughtError)
1337
+ });
1338
+ return baseline;
1339
+ }
1340
+ if (!mojangAvailable) {
1341
+ return baseline;
1342
+ }
1343
+ return {
1344
+ allowBinaryRemap: true,
1345
+ mappingVariant: "mojang-remapped",
1346
+ tinyRemapperJarPath,
1347
+ mojangTinyFilePath,
1348
+ warnings: baseline.warnings
1349
+ };
1350
+ }
883
1351
  buildArtifactContentsSummary(input) {
884
1352
  const sourceKind = input.isDecompiled || input.origin === "decompiled" || !normalizeOptionalString(input.sourceJarPath)
885
1353
  ? "decompiled-binary"
@@ -972,9 +1440,11 @@ export class SourceService {
972
1440
  let resolvedTarget = { kind, value };
973
1441
  let resolvedVersion;
974
1442
  let versionSourceDiscovery;
1443
+ let runtimeNamesUnobfuscated = false;
975
1444
  if (kind === "version") {
976
1445
  const versionJar = await this.versionService.resolveVersionJar(value);
977
1446
  resolvedVersion = versionJar.version;
1447
+ runtimeNamesUnobfuscated = isUnobfuscatedVersion(resolvedVersion);
978
1448
  resolvedTarget = {
979
1449
  kind: "jar",
980
1450
  value: versionJar.jarPath
@@ -989,6 +1459,9 @@ export class SourceService {
989
1459
  // coordinate validity is validated by resolver, keep version undefined on parse failure.
990
1460
  }
991
1461
  }
1462
+ if (!runtimeNamesUnobfuscated && resolvedVersion && isUnobfuscatedVersion(resolvedVersion)) {
1463
+ runtimeNamesUnobfuscated = true;
1464
+ }
992
1465
  // Unobfuscated versions (MC 26.1+) ship with deobfuscated runtime names; intermediary/yarn are not applicable.
993
1466
  let effectiveMapping = mapping;
994
1467
  if ((mapping === "intermediary" || mapping === "yarn") &&
@@ -997,7 +1470,11 @@ export class SourceService {
997
1470
  warnings.push(`Version ${resolvedVersion} is unobfuscated; ${mapping} mappings are not applicable. Using the obfuscated namespace label for the deobfuscated runtime names.`);
998
1471
  effectiveMapping = "obfuscated";
999
1472
  }
1000
- if (kind === "version" && resolvedVersion && effectiveMapping === "mojang" && scope !== "vanilla") {
1473
+ if (kind === "version" &&
1474
+ resolvedVersion &&
1475
+ effectiveMapping === "mojang" &&
1476
+ !runtimeNamesUnobfuscated &&
1477
+ scope !== "vanilla") {
1001
1478
  versionSourceDiscovery = await this.discoverVersionSourceJar({
1002
1479
  version: resolvedVersion,
1003
1480
  projectPath: input.projectPath
@@ -1010,10 +1487,29 @@ export class SourceService {
1010
1487
  warnings.push(`Resolved source-backed artifact from Loom cache candidate: ${versionSourceDiscovery.selectedSourceJarPath}.`);
1011
1488
  }
1012
1489
  }
1490
+ // The mojang binary-remap gate is only relevant when no source jar has been pre-selected.
1491
+ // If discoverVersionSourceJar already chose a source jar, the resolver will return a
1492
+ // source-backed artifact and applyMappingPipeline will take the source-backed branch,
1493
+ // which never consults allowBinaryRemap. Skipping the gate avoids paying for tiny-remapper
1494
+ // download / mojang tiny generation / mapping-health probe on healthy source-backed paths.
1495
+ const sourceJarPreSelected = Boolean(versionSourceDiscovery?.selectedSourceJarPath);
1496
+ const binaryRemapGate = sourceJarPreSelected
1497
+ ? { allowBinaryRemap: false, mappingVariant: "pass", warnings: [] }
1498
+ : await this.computeBinaryRemapGate({
1499
+ requestedMapping: effectiveMapping,
1500
+ runtimeNamesUnobfuscated,
1501
+ version: resolvedVersion,
1502
+ targetKind: kind,
1503
+ sourcePriority: input.sourcePriority
1504
+ });
1505
+ if (binaryRemapGate.warnings.length > 0) {
1506
+ warnings.push(...binaryRemapGate.warnings);
1507
+ }
1013
1508
  const resolved = await resolveSourceTargetInternal(resolvedTarget, {
1014
1509
  // mojang requires source-backed artifact guarantee; force resolution to consider decompile candidate
1015
1510
  // and reject later if mapping cannot be applied.
1016
1511
  allowDecompile: effectiveMapping === "mojang" ? true : input.allowDecompile ?? true,
1512
+ mappingVariant: binaryRemapGate.mappingVariant,
1017
1513
  onRepoFailover: (event) => {
1018
1514
  this.metrics.recordRepoFailover();
1019
1515
  log("warn", "repo.failover", {
@@ -1032,7 +1528,9 @@ export class SourceService {
1032
1528
  mappingDecision = applyMappingPipeline({
1033
1529
  requestedMapping: effectiveMapping,
1034
1530
  target: { kind, value },
1035
- resolved
1531
+ resolved,
1532
+ runtimeNamesUnobfuscated,
1533
+ allowBinaryRemap: binaryRemapGate.allowBinaryRemap
1036
1534
  });
1037
1535
  }
1038
1536
  catch (caughtError) {
@@ -1149,9 +1647,29 @@ export class SourceService {
1149
1647
  }
1150
1648
  }
1151
1649
  resolved.qualityFlags = dedupeQualityFlags(resolved.qualityFlags);
1650
+ // Use the resolver's canonical path (already normalizeJarPath-applied)
1651
+ // for the readable jar token. Without this, two requests for the same
1652
+ // artifact via a symlink path and the real path would share artifactId
1653
+ // but produce different alias readable tokens, and setAlias rotation
1654
+ // on the warm-cache hit would invalidate the alias returned to the
1655
+ // earlier caller. Coordinate / version paths are already canonical:
1656
+ // resolved.coordinate is normalized inside the resolver and
1657
+ // resolvedVersion comes from versionService.resolveVersionJar().
1658
+ const aliasValue = kind === "jar"
1659
+ ? (resolved.sourceJarPath ?? resolved.binaryJarPath ?? value)
1660
+ : value;
1661
+ const artifactAlias = buildArtifactAlias({
1662
+ artifactId: resolved.artifactId,
1663
+ kind,
1664
+ value: aliasValue,
1665
+ mappingVariant: binaryRemapGate.mappingVariant,
1666
+ resolvedVersion: resolvedVersion ?? resolved.version,
1667
+ coordinate: resolved.coordinate
1668
+ });
1669
+ resolved.artifactAlias = artifactAlias;
1152
1670
  await this.ingestIfNeeded(resolved);
1153
1671
  let sampleEntries;
1154
- if (resolved.sourceJarPath) {
1672
+ if (input.compact === false && resolved.sourceJarPath) {
1155
1673
  try {
1156
1674
  const javaEntries = await listJavaEntries(resolved.sourceJarPath);
1157
1675
  const MAX_SAMPLE = 10;
@@ -1166,6 +1684,7 @@ export class SourceService {
1166
1684
  }
1167
1685
  return {
1168
1686
  artifactId: resolved.artifactId,
1687
+ artifactAlias,
1169
1688
  origin: resolved.origin,
1170
1689
  isDecompiled: resolved.isDecompiled,
1171
1690
  resolvedSourceJarPath: resolved.sourceJarPath,
@@ -1210,8 +1729,8 @@ export class SourceService {
1210
1729
  const startedAt = Date.now();
1211
1730
  try {
1212
1731
  const artifact = this.getArtifact(input.artifactId);
1213
- const query = input.query.trim();
1214
- if (!query) {
1732
+ const originalQuery = input.query.trim();
1733
+ if (!originalQuery) {
1215
1734
  return {
1216
1735
  hits: [],
1217
1736
  mappingApplied: artifact.mappingApplied ?? "obfuscated",
@@ -1226,8 +1745,82 @@ export class SourceService {
1226
1745
  }
1227
1746
  const intent = normalizeIntent(input.intent);
1228
1747
  const match = normalizeMatch(input.match);
1229
- if (match === "regex" && query.length > MAX_REGEX_QUERY_LENGTH) {
1230
- throw createError({
1748
+ const artifactMapping = artifact.mappingApplied ?? "obfuscated";
1749
+ const searchWarnings = [];
1750
+ let translatedInfo;
1751
+ let query = originalQuery;
1752
+ let translationPackagePrefix;
1753
+ if (input.queryNamespace
1754
+ && input.queryNamespace !== artifactMapping
1755
+ && !artifact.version) {
1756
+ searchWarnings.push(`queryNamespace=${input.queryNamespace} could not be applied because the artifact has no version recorded; namespace translation requires a version. Running literal search in ${artifactMapping} instead.`);
1757
+ }
1758
+ if (input.queryNamespace
1759
+ && input.queryNamespace !== artifactMapping
1760
+ && artifact.version) {
1761
+ if (intent === "symbol" && originalQuery.includes(".") && /^[\w.$]+$/.test(originalQuery)) {
1762
+ try {
1763
+ const translated = await this.mappingService.findMapping({
1764
+ version: artifact.version,
1765
+ kind: "class",
1766
+ name: originalQuery,
1767
+ sourceMapping: input.queryNamespace,
1768
+ targetMapping: artifactMapping,
1769
+ sourcePriority: input.sourcePriority,
1770
+ signatureMode: "name-only",
1771
+ maxCandidates: 5
1772
+ });
1773
+ if (translated.resolved === true && translated.resolvedSymbol) {
1774
+ const resolvedName = translated.resolvedSymbol.symbol
1775
+ ?? translated.resolvedSymbol.name;
1776
+ if (resolvedName && resolvedName !== originalQuery) {
1777
+ translatedInfo = {
1778
+ original: originalQuery,
1779
+ translated: resolvedName,
1780
+ fromNamespace: input.queryNamespace,
1781
+ toNamespace: artifactMapping
1782
+ };
1783
+ // Downstream symbol search matches on simpleName only, so split
1784
+ // the translated FQCN into simpleName + derived packagePrefix
1785
+ // scope. Preserve a caller-supplied packagePrefix if present.
1786
+ if (resolvedName.includes(".")) {
1787
+ const lastDot = resolvedName.lastIndexOf(".");
1788
+ const simpleName = resolvedName.slice(lastDot + 1);
1789
+ const packagePart = resolvedName.slice(0, lastDot);
1790
+ query = simpleName;
1791
+ if (!input.scope?.packagePrefix) {
1792
+ translationPackagePrefix = packagePart;
1793
+ }
1794
+ }
1795
+ else {
1796
+ query = resolvedName;
1797
+ }
1798
+ }
1799
+ }
1800
+ else if (translated.status === "ambiguous") {
1801
+ const candidateCount = translated.candidateCount ?? translated.candidates?.length ?? 0;
1802
+ searchWarnings.push(`queryNamespace=${input.queryNamespace}: translation for "${originalQuery}" was ambiguous (${candidateCount} candidates); running literal search instead. Narrow the query with a more specific FQCN or call find-mapping directly.`);
1803
+ }
1804
+ else if (translated.status === "not_found") {
1805
+ searchWarnings.push(`queryNamespace=${input.queryNamespace}: no ${artifactMapping} mapping found for "${originalQuery}"; running literal search instead.`);
1806
+ }
1807
+ else if (translated.status === "mapping_unavailable") {
1808
+ searchWarnings.push(`queryNamespace=${input.queryNamespace}: mapping data unavailable for version ${artifact.version}; running literal search instead.`);
1809
+ }
1810
+ else {
1811
+ searchWarnings.push(`queryNamespace=${input.queryNamespace}: could not translate "${originalQuery}" to ${artifactMapping}; running literal search instead.`);
1812
+ }
1813
+ }
1814
+ catch (caughtError) {
1815
+ searchWarnings.push(`queryNamespace=${input.queryNamespace}: translation failed (${caughtError instanceof Error ? caughtError.message : String(caughtError)}); running literal search instead.`);
1816
+ }
1817
+ }
1818
+ else if (intent === "text" || intent === "path") {
1819
+ searchWarnings.push(`queryNamespace=${input.queryNamespace} has no effect when intent="${intent}" — ${intent} search is a literal match against the artifact's ${artifactMapping} index. Use intent="symbol" for namespace translation.`);
1820
+ }
1821
+ }
1822
+ if (match === "regex" && query.length > MAX_REGEX_QUERY_LENGTH) {
1823
+ throw createError({
1231
1824
  code: ERROR_CODES.INVALID_INPUT,
1232
1825
  message: `Regex query exceeds max length of ${MAX_REGEX_QUERY_LENGTH} characters.`,
1233
1826
  details: {
@@ -1239,7 +1832,9 @@ export class SourceService {
1239
1832
  const searchLimitCap = match === "regex"
1240
1833
  ? Math.max(1, Math.min(this.config.maxSearchHits, MAX_REGEX_RESULT_LIMIT))
1241
1834
  : this.config.maxSearchHits;
1242
- const scope = input.scope;
1835
+ const scope = translationPackagePrefix
1836
+ ? { ...(input.scope ?? {}), packagePrefix: translationPackagePrefix }
1837
+ : input.scope;
1243
1838
  if (scope?.symbolKind && intent !== "symbol") {
1244
1839
  throw createError({
1245
1840
  code: ERROR_CODES.INVALID_INPUT,
@@ -1348,7 +1943,9 @@ export class SourceService {
1348
1943
  sourceJarPath: artifact.sourceJarPath,
1349
1944
  isDecompiled: artifact.isDecompiled,
1350
1945
  qualityFlags: artifact.qualityFlags
1351
- })
1946
+ }),
1947
+ ...(translatedInfo ? { translatedQuery: translatedInfo } : {}),
1948
+ ...(searchWarnings.length > 0 ? { warnings: searchWarnings } : {})
1352
1949
  };
1353
1950
  }
1354
1951
  finally {
@@ -1461,7 +2058,14 @@ export class SourceService {
1461
2058
  return this.mappingService.getClassApiMatrix(input);
1462
2059
  }
1463
2060
  async checkSymbolExists(input) {
1464
- return this.mappingService.checkSymbolExists(input);
2061
+ const result = await this.mappingService.checkSymbolExists(input);
2062
+ if (result.status !== "mapping_unavailable" ||
2063
+ !isUnobfuscatedVersion(input.version) ||
2064
+ (input.sourceMapping !== "mojang" && input.sourceMapping !== "obfuscated")) {
2065
+ return result;
2066
+ }
2067
+ const runtimeFallback = await this.checkSymbolExistsInUnobfuscatedRuntime(input, result);
2068
+ return runtimeFallback ?? result;
1465
2069
  }
1466
2070
  async resolveWorkspaceSymbol(input) {
1467
2071
  const projectPath = input.projectPath?.trim();
@@ -1645,6 +2249,10 @@ export class SourceService {
1645
2249
  warnings: [...warnings, ...matrix.warnings]
1646
2250
  };
1647
2251
  }
2252
+ // By this point the method and class branches have already returned; only the field
2253
+ // branch reaches the generic findMapping fallthrough, and fields do not consume
2254
+ // signatureMode on the service side. Leave signatureMode undefined (service default =
2255
+ // "name-only" for any accidental future non-field caller hitting this path).
1648
2256
  const mapped = await this.mappingService.findMapping({
1649
2257
  version,
1650
2258
  kind,
@@ -1683,6 +2291,147 @@ export class SourceService {
1683
2291
  warnings: [...warnings, ...mapped.warnings]
1684
2292
  };
1685
2293
  }
2294
+ async checkSymbolExistsInUnobfuscatedRuntime(input, fallbackBase) {
2295
+ const version = input.version.trim();
2296
+ const name = input.name.trim();
2297
+ const owner = input.owner?.trim();
2298
+ if (!version || !name) {
2299
+ return undefined;
2300
+ }
2301
+ if (input.kind === "class" && input.nameMode !== "fqcn" && !name.includes(".")) {
2302
+ return {
2303
+ ...fallbackBase,
2304
+ warnings: [
2305
+ ...fallbackBase.warnings,
2306
+ `Version ${version} is unobfuscated, but short class name "${name}" could not be checked against runtime bytecode without a fully-qualified name.`
2307
+ ]
2308
+ };
2309
+ }
2310
+ const querySymbol = input.kind === "class"
2311
+ ? {
2312
+ kind: "class",
2313
+ name,
2314
+ symbol: name
2315
+ }
2316
+ : input.kind === "field"
2317
+ ? {
2318
+ kind: "field",
2319
+ owner,
2320
+ name,
2321
+ symbol: `${owner}.${name}`
2322
+ }
2323
+ : {
2324
+ kind: "method",
2325
+ owner,
2326
+ name,
2327
+ descriptor: input.descriptor?.trim(),
2328
+ symbol: `${owner}.${name}${input.descriptor?.trim() ?? ""}`
2329
+ };
2330
+ const targetClass = input.kind === "class" ? name : owner;
2331
+ if (!targetClass) {
2332
+ return fallbackBase;
2333
+ }
2334
+ let jarPath;
2335
+ try {
2336
+ ({ jarPath } = await this.versionService.resolveVersionJar(version));
2337
+ }
2338
+ catch {
2339
+ return undefined;
2340
+ }
2341
+ let signature;
2342
+ try {
2343
+ signature = await this.explorerService.getSignature({
2344
+ fqn: targetClass,
2345
+ jarPath,
2346
+ access: "all"
2347
+ });
2348
+ }
2349
+ catch {
2350
+ return {
2351
+ ...fallbackBase,
2352
+ querySymbol,
2353
+ warnings: [
2354
+ ...fallbackBase.warnings,
2355
+ `Version ${version} is unobfuscated; runtime bytecode lookup could not load class "${targetClass}".`
2356
+ ]
2357
+ };
2358
+ }
2359
+ const warnings = [
2360
+ ...fallbackBase.warnings,
2361
+ ...signature.warnings,
2362
+ `Version ${version} is unobfuscated; validated symbol existence against runtime bytecode.`
2363
+ ];
2364
+ const buildResolved = (resolvedSymbol) => ({
2365
+ ...fallbackBase,
2366
+ querySymbol,
2367
+ resolved: true,
2368
+ status: "resolved",
2369
+ resolvedSymbol,
2370
+ candidates: resolvedSymbol
2371
+ ? [{
2372
+ ...resolvedSymbol,
2373
+ matchKind: "exact",
2374
+ confidence: 1
2375
+ }]
2376
+ : [],
2377
+ candidateCount: resolvedSymbol ? 1 : 0,
2378
+ warnings
2379
+ });
2380
+ const buildUnresolved = (status) => ({
2381
+ ...fallbackBase,
2382
+ querySymbol,
2383
+ resolved: false,
2384
+ status,
2385
+ resolvedSymbol: undefined,
2386
+ candidates: [],
2387
+ candidateCount: 0,
2388
+ warnings
2389
+ });
2390
+ if (input.kind === "class") {
2391
+ return buildResolved({
2392
+ kind: "class",
2393
+ name,
2394
+ symbol: name
2395
+ });
2396
+ }
2397
+ if (input.kind === "field") {
2398
+ const matched = signature.fields.filter((field) => field.name === name);
2399
+ if (matched.length !== 1) {
2400
+ return buildUnresolved(matched.length > 1 ? "ambiguous" : "not_found");
2401
+ }
2402
+ return buildResolved({
2403
+ kind: "field",
2404
+ owner,
2405
+ name,
2406
+ symbol: `${owner}.${name}`
2407
+ });
2408
+ }
2409
+ const methodCandidates = signature.methods.filter((method) => method.name === name);
2410
+ if (input.signatureMode === "name-only") {
2411
+ if (methodCandidates.length !== 1) {
2412
+ return buildUnresolved(methodCandidates.length > 1 ? "ambiguous" : "not_found");
2413
+ }
2414
+ return buildResolved({
2415
+ kind: "method",
2416
+ owner,
2417
+ name,
2418
+ descriptor: methodCandidates[0]?.jvmDescriptor,
2419
+ symbol: `${owner}.${name}${methodCandidates[0]?.jvmDescriptor ?? ""}`
2420
+ });
2421
+ }
2422
+ const descriptor = input.descriptor?.trim();
2423
+ const matched = methodCandidates.filter((method) => method.jvmDescriptor === descriptor);
2424
+ if (matched.length !== 1) {
2425
+ return buildUnresolved(matched.length > 1 ? "ambiguous" : "not_found");
2426
+ }
2427
+ return buildResolved({
2428
+ kind: "method",
2429
+ owner,
2430
+ name,
2431
+ descriptor,
2432
+ symbol: `${owner}.${name}${descriptor ?? ""}`
2433
+ });
2434
+ }
1686
2435
  async traceSymbolLifecycle(input) {
1687
2436
  const mapping = normalizeMapping(input.mapping);
1688
2437
  const { className: userClassName, methodName: userMethodName, inlineDescriptor } = parseQualifiedMethodSymbol(input.symbol);
@@ -2129,15 +2878,18 @@ export class SourceService {
2129
2878
  message: "className must be non-empty."
2130
2879
  });
2131
2880
  }
2132
- const artifactId = input.artifactId.trim();
2133
- if (!artifactId) {
2881
+ const inputArtifactId = input.artifactId.trim();
2882
+ if (!inputArtifactId) {
2134
2883
  throw createError({
2135
2884
  code: ERROR_CODES.INVALID_INPUT,
2136
2885
  message: "artifactId must be non-empty."
2137
2886
  });
2138
2887
  }
2139
- // Verify artifact exists
2140
- const artifact = this.getArtifact(artifactId);
2888
+ // Verify artifact exists. The input may be either a 64-char artifactId or
2889
+ // an alias; normalize to the canonical row id so downstream symbolsRepo
2890
+ // calls (keyed by artifact_id only) do not silently miss for alias input.
2891
+ const artifact = this.getArtifact(inputArtifactId);
2892
+ const artifactId = artifact.artifactId;
2141
2893
  const limit = Math.max(1, Math.min(input.limit ?? 20, 200));
2142
2894
  const warnings = [];
2143
2895
  const isQualified = className.includes(".");
@@ -2294,6 +3046,11 @@ export class SourceService {
2294
3046
  }
2295
3047
  else {
2296
3048
  const artifact = this.getArtifact(artifactId);
3049
+ // Normalize alias input to the canonical row id so downstream
3050
+ // resolveClassFilePath / filesRepo lookups (keyed by 64-char
3051
+ // artifact_id only) do not silently miss when the caller passed an
3052
+ // alias from a previous resolveArtifact response.
3053
+ artifactId = artifact.artifactId;
2297
3054
  origin = artifact.origin;
2298
3055
  requestedMapping = artifact.requestedMapping ?? requestedMapping;
2299
3056
  mappingApplied = artifact.mappingApplied ?? requestedMapping;
@@ -2577,6 +3334,9 @@ export class SourceService {
2577
3334
  }
2578
3335
  else {
2579
3336
  const artifact = this.getArtifact(artifactId);
3337
+ // Normalize alias input to canonical row id; downstream files/symbols
3338
+ // lookups use this id directly.
3339
+ artifactId = artifact.artifactId;
2580
3340
  origin = artifact.origin;
2581
3341
  mappingApplied = artifact.mappingApplied ?? requestedMapping;
2582
3342
  provenance = artifact.provenance;
@@ -2683,6 +3443,36 @@ export class SourceService {
2683
3443
  requestedMapping,
2684
3444
  mappingApplied
2685
3445
  });
3446
+ let decompiledFallback;
3447
+ let decompiledMemberCounts;
3448
+ let fallbackQualityFlags = qualityFlags;
3449
+ if (counts.total === 0) {
3450
+ // When the request namespace differs from the artifact namespace, the
3451
+ // caller's memberPattern is authored against requested-namespace names
3452
+ // (e.g. a Mojang pattern). The fallback extracts artifact-namespace
3453
+ // names (e.g. obfuscated), so filtering by the raw pattern would
3454
+ // silently drop every entry. Skip the filter in that case and warn.
3455
+ const namespaceMismatch = requestedMapping !== mappingApplied;
3456
+ const fallbackPattern = namespaceMismatch ? undefined : memberPattern;
3457
+ const sourceFallback = this.buildDecompiledFallback(artifactId, lookupClassName, fallbackPattern, maxMembers);
3458
+ if (sourceFallback) {
3459
+ decompiledFallback = sourceFallback.fallback;
3460
+ decompiledMemberCounts = sourceFallback.counts;
3461
+ fallbackQualityFlags = dedupeQualityFlags([
3462
+ ...qualityFlags,
3463
+ "members-from-decompiled-source"
3464
+ ]);
3465
+ const namespaceNote = namespaceMismatch
3466
+ ? ` Member names are in ${mappingApplied} (artifact namespace); the request asked for ${requestedMapping}.`
3467
+ : "";
3468
+ warnings.push("Bytecode member enumeration returned zero; populated decompiledFallback from decompiled source. "
3469
+ + "Descriptors and access modifiers are unavailable — use get-class-source for full details."
3470
+ + namespaceNote);
3471
+ if (namespaceMismatch && memberPattern) {
3472
+ warnings.push(`memberPattern="${memberPattern}" was not applied to decompiledFallback because the artifact namespace (${mappingApplied}) differs from the requested namespace (${requestedMapping}); filter the response client-side after mapping.`);
3473
+ }
3474
+ }
3475
+ }
2686
3476
  return {
2687
3477
  className,
2688
3478
  members: {
@@ -2699,17 +3489,83 @@ export class SourceService {
2699
3489
  mappingApplied,
2700
3490
  returnedNamespace: requestedMapping,
2701
3491
  provenance: normalizedProvenance,
2702
- qualityFlags,
3492
+ qualityFlags: fallbackQualityFlags,
2703
3493
  artifactContents: this.buildArtifactContentsSummary({
2704
3494
  origin,
2705
3495
  sourceJarPath,
2706
3496
  isDecompiled: origin === "decompiled",
2707
- qualityFlags
3497
+ qualityFlags: fallbackQualityFlags
2708
3498
  }),
3499
+ ...(decompiledFallback ? { decompiledFallback } : {}),
3500
+ ...(decompiledMemberCounts ? { decompiledMemberCounts } : {}),
2709
3501
  warnings
2710
3502
  };
2711
3503
  }
3504
+ buildDecompiledFallback(artifactId, lookupClassName, memberPattern, maxMembers) {
3505
+ const filePath = this.resolveClassFilePath(artifactId, lookupClassName);
3506
+ if (!filePath) {
3507
+ return undefined;
3508
+ }
3509
+ const row = this.filesRepo.getFileContent(artifactId, filePath);
3510
+ if (!row) {
3511
+ return undefined;
3512
+ }
3513
+ const extracted = this.extractDecompiledMembers(lookupClassName, filePath, row.content);
3514
+ const filterByPattern = (list) => {
3515
+ if (!memberPattern) {
3516
+ return list;
3517
+ }
3518
+ const lower = memberPattern.toLowerCase();
3519
+ return list.filter((entry) => entry.name.toLowerCase().includes(lower));
3520
+ };
3521
+ let constructors = filterByPattern(extracted.constructors);
3522
+ let fields = filterByPattern(extracted.fields);
3523
+ let methods = filterByPattern(extracted.methods);
3524
+ const totalBefore = constructors.length + fields.length + methods.length;
3525
+ if (totalBefore === 0) {
3526
+ return undefined;
3527
+ }
3528
+ let remaining = maxMembers;
3529
+ const takeWithinLimit = (list) => {
3530
+ if (remaining <= 0) {
3531
+ return [];
3532
+ }
3533
+ const slice = list.slice(0, remaining);
3534
+ remaining -= slice.length;
3535
+ return slice;
3536
+ };
3537
+ constructors = takeWithinLimit(constructors);
3538
+ fields = takeWithinLimit(fields);
3539
+ methods = takeWithinLimit(methods);
3540
+ return {
3541
+ fallback: {
3542
+ constructors,
3543
+ fields,
3544
+ methods,
3545
+ origin: "source-extracted"
3546
+ },
3547
+ counts: {
3548
+ constructors: constructors.length,
3549
+ fields: fields.length,
3550
+ methods: methods.length,
3551
+ total: constructors.length + fields.length + methods.length
3552
+ }
3553
+ };
3554
+ }
2712
3555
  async validateMixin(input) {
3556
+ // Wrap the dispatcher so any untagged AppError coming out of path
3557
+ // normalization, preflight discovery, or config resolution surfaces with a
3558
+ // meaningful failedStage. annotateValidateMixinError preserves any
3559
+ // inner-pipeline tag (resolve/mapping-health/parse/target-lookup) so only
3560
+ // the dispatcher-level errors default to input-validation.
3561
+ try {
3562
+ return await this.runValidateMixinDispatcher(input);
3563
+ }
3564
+ catch (err) {
3565
+ throw annotateValidateMixinError(err, "input-validation");
3566
+ }
3567
+ }
3568
+ async runValidateMixinDispatcher(input) {
2713
3569
  const { input: sourceInput, ...sharedInput } = input;
2714
3570
  const mode = sourceInput.mode;
2715
3571
  if (mode === "inline") {
@@ -2755,7 +3611,7 @@ export class SourceService {
2755
3611
  })), input, []);
2756
3612
  }
2757
3613
  const resolvedInput = mode === "project"
2758
- ? this.createProjectValidateMixinConfigInput(input)
3614
+ ? await this.createProjectValidateMixinConfigInput(input)
2759
3615
  : input;
2760
3616
  const { sources: configSources, warnings: configWarnings } = await this.resolveMixinConfigSources(resolvedInput);
2761
3617
  if (configSources.length === 0) {
@@ -2775,22 +3631,23 @@ export class SourceService {
2775
3631
  sourcePath: entry.sourcePath
2776
3632
  })), resolvedInput, configWarnings);
2777
3633
  }
2778
- createProjectValidateMixinConfigInput(input) {
3634
+ async createProjectValidateMixinConfigInput(input) {
2779
3635
  if (input.input.mode !== "project") {
2780
3636
  return input;
2781
3637
  }
2782
3638
  const resolvedProjectPath = this.resolveMixinInputPath(input.input.path, "path");
2783
- const configPaths = fastGlob.sync(["**/*.mixins.json"], {
3639
+ const configPaths = (await fastGlob.glob(["**/*.mixins.json"], {
2784
3640
  cwd: resolvedProjectPath,
2785
3641
  absolute: true,
2786
3642
  onlyFiles: true,
2787
3643
  ignore: [...MIXIN_PROJECT_DISCOVERY_IGNORES]
2788
- }).sort((left, right) => left.localeCompare(right));
3644
+ })).sort((left, right) => left.localeCompare(right));
2789
3645
  if (configPaths.length === 0) {
2790
3646
  throw createError({
2791
3647
  code: ERROR_CODES.INVALID_INPUT,
2792
3648
  message: `No mixin config JSON files were found under project path "${input.input.path}".`,
2793
3649
  details: {
3650
+ failedStage: "input-validation",
2794
3651
  nextAction: "Use input.mode='config' with explicit configPaths[], or point input.path at the workspace root that contains *.mixins.json files."
2795
3652
  }
2796
3653
  });
@@ -2828,7 +3685,8 @@ export class SourceService {
2828
3685
  name: input.className,
2829
3686
  sourceMapping: input.sourceMapping,
2830
3687
  targetMapping: input.targetMapping,
2831
- sourcePriority: input.sourcePriority
3688
+ sourcePriority: input.sourcePriority,
3689
+ projectPath: input.projectPath
2832
3690
  });
2833
3691
  }
2834
3692
  const cacheKey = [
@@ -2836,7 +3694,8 @@ export class SourceService {
2836
3694
  input.className,
2837
3695
  input.sourceMapping,
2838
3696
  input.targetMapping,
2839
- input.sourcePriority
3697
+ input.sourcePriority,
3698
+ input.projectPath ?? ""
2840
3699
  ].join("\0");
2841
3700
  const cached = cache.get(cacheKey);
2842
3701
  if (cached) {
@@ -2848,7 +3707,8 @@ export class SourceService {
2848
3707
  name: input.className,
2849
3708
  sourceMapping: input.sourceMapping,
2850
3709
  targetMapping: input.targetMapping,
2851
- sourcePriority: input.sourcePriority
3710
+ sourcePriority: input.sourcePriority,
3711
+ projectPath: input.projectPath
2852
3712
  }).catch((error) => {
2853
3713
  cache.delete(cacheKey);
2854
3714
  throw error;
@@ -2857,37 +3717,71 @@ export class SourceService {
2857
3717
  return pending;
2858
3718
  }
2859
3719
  async validateMixinSingle(input) {
2860
- let version = input.version.trim();
2861
- const requestedScope = normalizeRequestedArtifactScope(input.scope);
2862
- const currentSourcePriority = input.sourcePriority ?? this.config.mappingSourcePriority;
2863
- const initialSourcePriority = input.retryState?.initialSourcePriority ?? currentSourcePriority;
2864
- if (!version) {
2865
- throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "version must be non-empty." });
2866
- }
2867
- // Resolve source from source or sourcePath
2868
- let source;
2869
- if (input.sourcePath) {
2870
- const normalizedSourcePath = normalizePathForHost(input.sourcePath, undefined, "sourcePath");
2871
- const resolvedSourcePath = isAbsolute(normalizedSourcePath)
2872
- ? normalizedSourcePath
2873
- : resolvePath(process.cwd(), normalizedSourcePath);
2874
- try {
2875
- source = await readFile(resolvedSourcePath, "utf-8");
3720
+ // Start at input-validation so path normalization, file reads, and the
3721
+ // simple guard checks all land under that stage. The pipeline callback
3722
+ // shifts the stage tracker before the first non-input-validation action,
3723
+ // and annotateValidateMixinError() preserves any nested-call stage
3724
+ // (e.g. a deeper resolver that set failedStage="version-manifest").
3725
+ let currentStage = "input-validation";
3726
+ try {
3727
+ let version = input.version.trim();
3728
+ const requestedScope = normalizeRequestedArtifactScope(input.scope);
3729
+ const currentSourcePriority = input.sourcePriority ?? this.config.mappingSourcePriority;
3730
+ const initialSourcePriority = input.retryState?.initialSourcePriority ?? currentSourcePriority;
3731
+ if (!version) {
3732
+ throw createError({
3733
+ code: ERROR_CODES.INVALID_INPUT,
3734
+ message: "version must be non-empty.",
3735
+ details: { failedStage: "input-validation" }
3736
+ });
2876
3737
  }
2877
- catch (err) {
3738
+ // Resolve source from source or sourcePath
3739
+ let source;
3740
+ if (input.sourcePath) {
3741
+ const normalizedSourcePath = normalizePathForHost(input.sourcePath, undefined, "sourcePath");
3742
+ const resolvedSourcePath = isAbsolute(normalizedSourcePath)
3743
+ ? normalizedSourcePath
3744
+ : resolvePath(process.cwd(), normalizedSourcePath);
3745
+ try {
3746
+ source = await readFile(resolvedSourcePath, "utf-8");
3747
+ }
3748
+ catch (err) {
3749
+ throw createError({
3750
+ code: ERROR_CODES.INVALID_INPUT,
3751
+ message: `Could not read sourcePath "${input.sourcePath}" (resolved to "${resolvedSourcePath}"):` +
3752
+ ` ${err instanceof Error ? err.message : String(err)}`,
3753
+ details: { failedStage: "input-validation" }
3754
+ });
3755
+ }
3756
+ }
3757
+ else {
3758
+ source = input.source ?? "";
3759
+ }
3760
+ if (!source.trim()) {
2878
3761
  throw createError({
2879
3762
  code: ERROR_CODES.INVALID_INPUT,
2880
- message: `Could not read sourcePath "${input.sourcePath}" (resolved to "${resolvedSourcePath}"):` +
2881
- ` ${err instanceof Error ? err.message : String(err)}`
3763
+ message: "source must be non-empty.",
3764
+ details: { failedStage: "input-validation" }
2882
3765
  });
2883
3766
  }
3767
+ return await this.runValidateMixinPipeline({
3768
+ input,
3769
+ version,
3770
+ source,
3771
+ requestedScope,
3772
+ currentSourcePriority,
3773
+ initialSourcePriority,
3774
+ onStage: (stage) => { currentStage = stage; }
3775
+ });
2884
3776
  }
2885
- else {
2886
- source = input.source ?? "";
2887
- }
2888
- if (!source.trim()) {
2889
- throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "source must be non-empty." });
3777
+ catch (err) {
3778
+ throw annotateValidateMixinError(err, currentStage);
2890
3779
  }
3780
+ }
3781
+ async runValidateMixinPipeline(ctx) {
3782
+ const { input, source, requestedScope, currentSourcePriority, initialSourcePriority, onStage } = ctx;
3783
+ let { version } = ctx;
3784
+ onStage("resolve");
2891
3785
  const warnings = [];
2892
3786
  let mappingAutoDetected = false;
2893
3787
  // Auto-detect mapping from project config when not explicitly provided (or when preferProjectMapping is set)
@@ -2968,6 +3862,7 @@ export class SourceService {
2968
3862
  };
2969
3863
  }
2970
3864
  // Health check: probe mapping infrastructure
3865
+ onStage("mapping-health");
2971
3866
  let healthReport;
2972
3867
  try {
2973
3868
  const health = await this.mappingService.checkMappingHealth({
@@ -2989,10 +3884,24 @@ export class SourceService {
2989
3884
  ]
2990
3885
  };
2991
3886
  }
2992
- catch {
2993
- // Health check failed — proceed without it
3887
+ catch (err) {
3888
+ // Probe itself failed — surface the degradation instead of swallowing it
3889
+ // silently, so quickSummary and toolHealth reflect the unknown-health
3890
+ // state rather than looking clean.
3891
+ const reason = err instanceof Error ? err.message : String(err);
3892
+ healthReport = {
3893
+ jarAvailable: existsSync(jarPath),
3894
+ jarPath,
3895
+ mojangMappingsAvailable: false,
3896
+ tinyMappingsAvailable: false,
3897
+ memberRemapAvailable: false,
3898
+ overallHealthy: false,
3899
+ degradations: [`Mapping health probe failed: ${reason}`]
3900
+ };
2994
3901
  }
3902
+ onStage("parse");
2995
3903
  const parsed = parseMixinSource(source);
3904
+ onStage("target-lookup");
2996
3905
  const targetMembers = new Map();
2997
3906
  const mappingFailedTargets = new Set();
2998
3907
  const remapFailedMembers = new Map();
@@ -3029,6 +3938,7 @@ export class SourceService {
3029
3938
  sourceMapping: requestedMapping,
3030
3939
  targetMapping: signatureLookupMapping,
3031
3940
  sourcePriority: currentSourcePriority,
3941
+ projectPath: input.projectPath,
3032
3942
  batchCaches: input.batchCaches
3033
3943
  });
3034
3944
  if (mapped.resolved && mapped.resolvedSymbol) {
@@ -3062,9 +3972,9 @@ export class SourceService {
3062
3972
  if (requestedMapping !== signatureLookupMapping) {
3063
3973
  try {
3064
3974
  const [ctorResult, methodResult, fieldResult] = await Promise.all([
3065
- this.remapSignatureMembers(sig.constructors, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings),
3066
- this.remapSignatureMembers(sig.methods, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings),
3067
- this.remapSignatureMembers(sig.fields, "field", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings)
3975
+ this.remapSignatureMembers(sig.constructors, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings, input.projectPath),
3976
+ this.remapSignatureMembers(sig.methods, "method", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings, input.projectPath),
3977
+ this.remapSignatureMembers(sig.fields, "field", version, signatureLookupMapping, requestedMapping, currentSourcePriority, warnings, input.projectPath)
3068
3978
  ]);
3069
3979
  constructors = ctorResult.members;
3070
3980
  methods = methodResult.members;
@@ -3252,8 +4162,12 @@ export class SourceService {
3252
4162
  if (result.structuredWarnings.length === 0)
3253
4163
  result.structuredWarnings = undefined;
3254
4164
  }
3255
- // Apply compact report mode
4165
+ // Apply compact report mode. refreshMixinValidationOutcome reads
4166
+ // result.toolHealth / result.provenance when it rebuilds quickSummary, so
4167
+ // refresh BEFORE stripping those fields; otherwise compact mode would
4168
+ // silently drop the mapping-health-degraded and scope-fallback notes.
3256
4169
  if (input.reportMode === "compact") {
4170
+ refreshMixinValidationOutcome(result);
3257
4171
  result.resolvedMembers = undefined;
3258
4172
  result.structuredWarnings = undefined;
3259
4173
  result.aggregatedWarnings = undefined;
@@ -3263,7 +4177,9 @@ export class SourceService {
3263
4177
  result.provenance.resolutionTrace = undefined;
3264
4178
  }
3265
4179
  }
3266
- refreshMixinValidationOutcome(result);
4180
+ else {
4181
+ refreshMixinValidationOutcome(result);
4182
+ }
3267
4183
  if (this.shouldRetryValidateMixinWithMavenFirst(input, result)) {
3268
4184
  const retryWarning = `Retrying validate-mixin with sourcePriority="maven-first" after partial validation using "${currentSourcePriority}".`;
3269
4185
  try {
@@ -3286,7 +4202,14 @@ export class SourceService {
3286
4202
  `Validation retried with sourcePriority "maven-first" after partial result from "${currentSourcePriority}".`
3287
4203
  ];
3288
4204
  }
3289
- return refreshMixinValidationOutcome(retried);
4205
+ // The recursive validateMixinSingle call already produced final summary /
4206
+ // status / quickSummary. The only mutations above are warnings and
4207
+ // provenance.resolutionNotes, which quickSummary does not read. Calling
4208
+ // refreshMixinValidationOutcome(retried) here would rebuild quickSummary
4209
+ // against the already-compacted `retried.toolHealth === undefined`, which
4210
+ // silently strips the mapping-health-degraded note we just preserved in
4211
+ // the compact branch above.
4212
+ return retried;
3290
4213
  }
3291
4214
  catch (retryErr) {
3292
4215
  result.warnings.unshift(`${retryWarning} Retry failed: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
@@ -3320,7 +4243,8 @@ export class SourceService {
3320
4243
  catch (err) {
3321
4244
  throw createError({
3322
4245
  code: ERROR_CODES.INVALID_INPUT,
3323
- message: `Could not read/parse mixin config "${rawConfigPath}": ${err instanceof Error ? err.message : String(err)}`
4246
+ message: `Could not read/parse mixin config "${rawConfigPath}": ${err instanceof Error ? err.message : String(err)}`,
4247
+ details: { failedStage: "input-validation" }
3324
4248
  });
3325
4249
  }
3326
4250
  const pkg = configJson.package ?? "";
@@ -3341,11 +4265,21 @@ export class SourceService {
3341
4265
  sourceRootCandidates = input.sourceRoots;
3342
4266
  }
3343
4267
  else {
3344
- const detected = COMMON_SOURCE_ROOTS.filter((candidateRoot) => classNames.some((className) => {
3345
- const fqcn = pkg ? `${pkg}.${className}` : className;
3346
- const relative = fqcn.replace(/\./g, "/") + ".java";
3347
- return existsSync(resolvePath(projectBase, candidateRoot, relative));
3348
- }));
4268
+ const detected = [];
4269
+ for (const candidateRoot of COMMON_SOURCE_ROOTS) {
4270
+ let foundInRoot = false;
4271
+ for (const className of classNames) {
4272
+ const fqcn = pkg ? `${pkg}.${className}` : className;
4273
+ const relative = fqcn.replace(/\./g, "/") + ".java";
4274
+ if (await pathExists(resolvePath(projectBase, candidateRoot, relative))) {
4275
+ foundInRoot = true;
4276
+ break;
4277
+ }
4278
+ }
4279
+ if (foundInRoot) {
4280
+ detected.push(candidateRoot);
4281
+ }
4282
+ }
3349
4283
  sourceRootCandidates = detected.length > 0 ? detected : ["src/main/java"];
3350
4284
  }
3351
4285
  for (const cls of classNames) {
@@ -3354,7 +4288,7 @@ export class SourceService {
3354
4288
  let sourcePath = resolvePath(projectBase, sourceRootCandidates[0], relativePath);
3355
4289
  for (const root of sourceRootCandidates) {
3356
4290
  const candidate = resolvePath(projectBase, root, relativePath);
3357
- if (existsSync(candidate)) {
4291
+ if (await pathExists(candidate)) {
3358
4292
  sourcePath = candidate;
3359
4293
  break;
3360
4294
  }
@@ -3545,7 +4479,6 @@ export class SourceService {
3545
4479
  throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "content must be non-empty." });
3546
4480
  }
3547
4481
  const warnings = [];
3548
- const { jarPath } = await this.versionService.resolveVersionJar(version);
3549
4482
  const parsed = parseAccessWidener(content);
3550
4483
  const headerNamespaceRaw = normalizeOptionalString(parsed.namespace);
3551
4484
  const overrideMapping = input.mapping ? normalizeMapping(input.mapping) : undefined;
@@ -3557,7 +4490,27 @@ export class SourceService {
3557
4490
  if (overrideMapping && headerNamespace && overrideMapping !== headerNamespace) {
3558
4491
  warnings.push(`Using mapping override "${overrideMapping}" instead of header namespace "${headerNamespaceRaw}".`);
3559
4492
  }
3560
- const needsMapping = awNamespace !== "obfuscated";
4493
+ const runtimeAware = input.projectPath != null || input.scope != null || input.preferProjectVersion === true;
4494
+ let resolvedVersion = version;
4495
+ let jarPath;
4496
+ let lookupMapping = "obfuscated";
4497
+ let provenance;
4498
+ if (runtimeAware) {
4499
+ provenance = await this.resolveAccessWidenerRuntimeArtifact({
4500
+ version,
4501
+ awNamespace,
4502
+ projectPath: input.projectPath,
4503
+ scope: input.scope,
4504
+ preferProjectVersion: input.preferProjectVersion
4505
+ });
4506
+ resolvedVersion = provenance.version;
4507
+ jarPath = provenance.jarPath;
4508
+ lookupMapping = provenance.mappingApplied;
4509
+ }
4510
+ else {
4511
+ ({ jarPath } = await this.versionService.resolveVersionJar(version));
4512
+ }
4513
+ const needsLookupMapping = awNamespace !== lookupMapping;
3561
4514
  // Collect unique class FQNs from entries
3562
4515
  const classFqns = new Set();
3563
4516
  for (const entry of parsed.entries) {
@@ -3566,22 +4519,23 @@ export class SourceService {
3566
4519
  }
3567
4520
  const membersByClass = new Map();
3568
4521
  for (const fqn of classFqns) {
3569
- let obfuscatedFqn = fqn;
3570
- if (needsMapping) {
4522
+ let lookupFqn = fqn;
4523
+ if (needsLookupMapping) {
3571
4524
  try {
3572
4525
  const mapped = await this.mappingService.findMapping({
3573
- version,
4526
+ version: resolvedVersion,
3574
4527
  kind: "class",
3575
4528
  name: fqn,
3576
4529
  sourceMapping: awNamespace,
3577
- targetMapping: "obfuscated",
3578
- sourcePriority: input.sourcePriority
4530
+ targetMapping: lookupMapping,
4531
+ sourcePriority: input.sourcePriority,
4532
+ projectPath: input.projectPath
3579
4533
  });
3580
4534
  if (mapped.resolved && mapped.resolvedSymbol) {
3581
- obfuscatedFqn = mapped.resolvedSymbol.name;
4535
+ lookupFqn = mapped.resolvedSymbol.name;
3582
4536
  }
3583
4537
  else {
3584
- warnings.push(`Could not map class "${fqn}" from ${awNamespace} to obfuscated.`);
4538
+ warnings.push(`Could not map class "${fqn}" from ${awNamespace} to ${lookupMapping}.`);
3585
4539
  }
3586
4540
  }
3587
4541
  catch {
@@ -3590,25 +4544,163 @@ export class SourceService {
3590
4544
  }
3591
4545
  try {
3592
4546
  const sig = await this.explorerService.getSignature({
3593
- fqn: obfuscatedFqn,
4547
+ fqn: lookupFqn,
4548
+ jarPath,
4549
+ access: "all"
4550
+ });
4551
+ warnings.push(...sig.warnings);
4552
+ let constructors = sig.constructors;
4553
+ let methods = sig.methods;
4554
+ let fields = sig.fields;
4555
+ if (needsLookupMapping) {
4556
+ const [ctorResult, methodResult, fieldResult] = await Promise.all([
4557
+ this.remapSignatureMembers(sig.constructors, "method", resolvedVersion, lookupMapping, awNamespace, input.sourcePriority, warnings, input.projectPath),
4558
+ this.remapSignatureMembers(sig.methods, "method", resolvedVersion, lookupMapping, awNamespace, input.sourcePriority, warnings, input.projectPath),
4559
+ this.remapSignatureMembers(sig.fields, "field", resolvedVersion, lookupMapping, awNamespace, input.sourcePriority, warnings, input.projectPath)
4560
+ ]);
4561
+ constructors = ctorResult.members;
4562
+ methods = methodResult.members;
4563
+ fields = fieldResult.members;
4564
+ }
4565
+ membersByClass.set(fqn, {
4566
+ className: fqn,
4567
+ classAccessFlags: sig.classAccessFlags,
4568
+ constructors,
4569
+ methods,
4570
+ fields
4571
+ });
4572
+ }
4573
+ catch {
4574
+ warnings.push(`Could not load signature for class "${lookupFqn}".`);
4575
+ }
4576
+ }
4577
+ const result = validateParsedAccessWidener(parsed, membersByClass, warnings, {
4578
+ includeRuntimeEvidence: runtimeAware
4579
+ });
4580
+ if (provenance) {
4581
+ result.provenance = provenance;
4582
+ }
4583
+ return result;
4584
+ }
4585
+ async validateAccessTransformer(input) {
4586
+ const version = input.version.trim();
4587
+ if (!version) {
4588
+ throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "version must be non-empty." });
4589
+ }
4590
+ const content = input.content;
4591
+ if (!content.trim()) {
4592
+ throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "content must be non-empty." });
4593
+ }
4594
+ const warnings = [];
4595
+ const parsed = parseAccessTransformer(content);
4596
+ const atNamespace = await this.resolveAccessTransformerNamespace({
4597
+ atNamespace: input.atNamespace,
4598
+ projectPath: input.projectPath
4599
+ });
4600
+ const runtimeAware = input.projectPath != null || input.scope != null || input.preferProjectVersion === true;
4601
+ let resolvedVersion = version;
4602
+ let jarPath;
4603
+ let lookupMapping = "obfuscated";
4604
+ let provenance;
4605
+ if (runtimeAware) {
4606
+ provenance = await this.resolveAccessTransformerRuntimeArtifact({
4607
+ version,
4608
+ atNamespace,
4609
+ projectPath: input.projectPath,
4610
+ scope: input.scope,
4611
+ preferProjectVersion: input.preferProjectVersion
4612
+ });
4613
+ resolvedVersion = provenance.version;
4614
+ jarPath = provenance.jarPath;
4615
+ lookupMapping = provenance.mappingApplied;
4616
+ }
4617
+ else {
4618
+ if (atNamespace === "srg") {
4619
+ throw createError({
4620
+ code: ERROR_CODES.INVALID_INPUT,
4621
+ message: "atNamespace=srg requires projectPath and scope=loader so a Forge runtime jar can be resolved."
4622
+ });
4623
+ }
4624
+ ({ jarPath } = await this.versionService.resolveVersionJar(version));
4625
+ }
4626
+ const needsLookupMapping = atNamespace !== lookupMapping;
4627
+ const classFqns = new Set(parsed.entries.map((entry) => entry.owner));
4628
+ const membersByClass = new Map();
4629
+ for (const fqn of classFqns) {
4630
+ let lookupFqn = fqn;
4631
+ if (needsLookupMapping) {
4632
+ if (!isSourceMappingNamespace(atNamespace) || !isSourceMappingNamespace(lookupMapping)) {
4633
+ warnings.push(`Could not map class "${fqn}" from ${atNamespace} to ${lookupMapping}.`);
4634
+ }
4635
+ else {
4636
+ try {
4637
+ const mapped = await this.mappingService.findMapping({
4638
+ version: resolvedVersion,
4639
+ kind: "class",
4640
+ name: fqn,
4641
+ sourceMapping: atNamespace,
4642
+ targetMapping: lookupMapping,
4643
+ sourcePriority: input.sourcePriority,
4644
+ projectPath: input.projectPath
4645
+ });
4646
+ if (mapped.resolved && mapped.resolvedSymbol) {
4647
+ lookupFqn = mapped.resolvedSymbol.name;
4648
+ }
4649
+ else {
4650
+ warnings.push(`Could not map class "${fqn}" from ${atNamespace} to ${lookupMapping}.`);
4651
+ }
4652
+ }
4653
+ catch {
4654
+ warnings.push(`Mapping lookup failed for class "${fqn}".`);
4655
+ }
4656
+ }
4657
+ }
4658
+ try {
4659
+ const sig = await this.explorerService.getSignature({
4660
+ fqn: lookupFqn,
3594
4661
  jarPath,
3595
4662
  access: "all"
3596
4663
  });
3597
4664
  warnings.push(...sig.warnings);
4665
+ let constructors = sig.constructors;
4666
+ let methods = sig.methods;
4667
+ let fields = sig.fields;
4668
+ if (needsLookupMapping && isSourceMappingNamespace(atNamespace) && isSourceMappingNamespace(lookupMapping)) {
4669
+ const [ctorResult, methodResult, fieldResult] = await Promise.all([
4670
+ this.remapSignatureMembers(sig.constructors, "method", resolvedVersion, lookupMapping, atNamespace, input.sourcePriority, warnings, input.projectPath),
4671
+ this.remapSignatureMembers(sig.methods, "method", resolvedVersion, lookupMapping, atNamespace, input.sourcePriority, warnings, input.projectPath),
4672
+ this.remapSignatureMembers(sig.fields, "field", resolvedVersion, lookupMapping, atNamespace, input.sourcePriority, warnings, input.projectPath)
4673
+ ]);
4674
+ constructors = ctorResult.members;
4675
+ methods = methodResult.members;
4676
+ fields = fieldResult.members;
4677
+ }
3598
4678
  membersByClass.set(fqn, {
3599
4679
  className: fqn,
3600
- constructors: sig.constructors,
3601
- methods: sig.methods,
3602
- fields: sig.fields
4680
+ classAccessFlags: sig.classAccessFlags,
4681
+ constructors,
4682
+ methods,
4683
+ fields
3603
4684
  });
3604
4685
  }
3605
4686
  catch {
3606
- warnings.push(`Could not load signature for class "${obfuscatedFqn}".`);
4687
+ warnings.push(`Could not load signature for class "${lookupFqn}".`);
3607
4688
  }
3608
4689
  }
3609
- return validateParsedAccessWidener(parsed, membersByClass, warnings);
4690
+ const result = validateParsedAccessTransformer(parsed, membersByClass, warnings, {
4691
+ includeRuntimeEvidence: runtimeAware
4692
+ });
4693
+ if (provenance) {
4694
+ result.provenance = provenance;
4695
+ }
4696
+ return result;
4697
+ }
4698
+ recordToolCall(tool, durationMs) {
4699
+ this.metrics.recordToolCall(tool, durationMs);
3610
4700
  }
3611
4701
  getRuntimeMetrics() {
4702
+ this.snapshotLruAccounting();
4703
+ this.metrics.setMappingResolutionCacheStats(this.mappingService.resolutionCacheStats);
3612
4704
  return this.metrics.snapshot();
3613
4705
  }
3614
4706
  async indexArtifact(input) {
@@ -3936,6 +5028,117 @@ export class SourceService {
3936
5028
  }
3937
5029
  return outputParts.join("\n");
3938
5030
  }
5031
+ extractDecompiledMembers(className, filePath, content) {
5032
+ const symbols = extractSymbolsFromSource(filePath, content);
5033
+ const simpleName = className.split(/[.$]/).at(-1) ?? className;
5034
+ const lines = content.split(/\r?\n/);
5035
+ const body = this.computeBraceRange(lines, symbols, simpleName);
5036
+ if (!body) {
5037
+ return { constructors: [], fields: [], methods: [] };
5038
+ }
5039
+ const depths = this.computeLineBraceDepths(lines);
5040
+ const baseDepth = depths[body.declarationLine - 1] ?? 0;
5041
+ const nestedRanges = this.computeNestedTypeRanges(lines, symbols, body);
5042
+ const constructors = [];
5043
+ const fields = [];
5044
+ const methods = [];
5045
+ for (const symbol of symbols) {
5046
+ if (symbol.line <= body.declarationLine || symbol.line > body.endLine) {
5047
+ continue;
5048
+ }
5049
+ if (nestedRanges.some((range) => symbol.line >= range.declarationLine && symbol.line <= range.endLine)) {
5050
+ continue;
5051
+ }
5052
+ const lineDepth = depths[symbol.line - 1] ?? baseDepth;
5053
+ // Declarations directly inside the class body sit at baseDepth+1; anything
5054
+ // deeper is a method/constructor body, an initializer block, etc.
5055
+ if (lineDepth !== baseDepth + 1) {
5056
+ continue;
5057
+ }
5058
+ if (symbol.symbolKind === "method") {
5059
+ if (symbol.symbolName === simpleName) {
5060
+ constructors.push({ name: "<init>", line: symbol.line, kind: "constructor" });
5061
+ }
5062
+ else {
5063
+ methods.push({ name: symbol.symbolName, line: symbol.line, kind: "method" });
5064
+ }
5065
+ }
5066
+ else if (symbol.symbolKind === "field") {
5067
+ fields.push({ name: symbol.symbolName, line: symbol.line, kind: "field" });
5068
+ }
5069
+ }
5070
+ return { constructors, fields, methods };
5071
+ }
5072
+ computeLineBraceDepths(lines) {
5073
+ const depths = new Array(lines.length).fill(0);
5074
+ let depth = 0;
5075
+ for (let i = 0; i < lines.length; i += 1) {
5076
+ // Entry depth for this line = depth observed before any brace on it.
5077
+ depths[i] = depth;
5078
+ const stripped = (lines[i] ?? "")
5079
+ .replace(/\/\/.*/g, "")
5080
+ .replace(/"(?:\\.|[^"\\])*"/g, "\"\"")
5081
+ .replace(/'(?:\\.|[^'\\])*'/g, "''");
5082
+ for (const char of stripped) {
5083
+ if (char === "{") {
5084
+ depth += 1;
5085
+ }
5086
+ else if (char === "}") {
5087
+ depth -= 1;
5088
+ }
5089
+ }
5090
+ }
5091
+ return depths;
5092
+ }
5093
+ computeBraceRange(lines, symbols, simpleName) {
5094
+ const classSymbol = symbols.find((symbol) => (symbol.symbolKind === "class" || symbol.symbolKind === "interface"
5095
+ || symbol.symbolKind === "enum" || symbol.symbolKind === "record")
5096
+ && symbol.symbolName === simpleName);
5097
+ if (!classSymbol) {
5098
+ return undefined;
5099
+ }
5100
+ return this.scanBraceRange(lines, classSymbol.line);
5101
+ }
5102
+ scanBraceRange(lines, declarationLine) {
5103
+ let depth = 0;
5104
+ let started = false;
5105
+ for (let i = declarationLine - 1; i < lines.length; i += 1) {
5106
+ const stripped = (lines[i] ?? "")
5107
+ .replace(/\/\/.*/g, "")
5108
+ .replace(/"(?:\\.|[^"\\])*"/g, "\"\"");
5109
+ for (const char of stripped) {
5110
+ if (char === "{") {
5111
+ depth += 1;
5112
+ started = true;
5113
+ }
5114
+ else if (char === "}") {
5115
+ depth -= 1;
5116
+ if (started && depth === 0) {
5117
+ return { declarationLine, endLine: i + 1 };
5118
+ }
5119
+ }
5120
+ }
5121
+ }
5122
+ return { declarationLine, endLine: lines.length };
5123
+ }
5124
+ computeNestedTypeRanges(lines, symbols, outerBody) {
5125
+ const ranges = [];
5126
+ for (const candidate of symbols) {
5127
+ if (candidate.symbolKind !== "class" && candidate.symbolKind !== "interface"
5128
+ && candidate.symbolKind !== "enum" && candidate.symbolKind !== "record") {
5129
+ continue;
5130
+ }
5131
+ if (candidate.line <= outerBody.declarationLine || candidate.line > outerBody.endLine) {
5132
+ continue;
5133
+ }
5134
+ if (ranges.some((range) => candidate.line >= range.declarationLine && candidate.line <= range.endLine)) {
5135
+ continue;
5136
+ }
5137
+ const nestedRange = this.scanBraceRange(lines, candidate.line);
5138
+ ranges.push(nestedRange);
5139
+ }
5140
+ return ranges;
5141
+ }
3939
5142
  resolveClassFilePath(artifactId, className) {
3940
5143
  const normalizedClassName = className.trim();
3941
5144
  const classPath = classNameToClassPath(normalizedClassName);
@@ -4188,7 +5391,15 @@ export class SourceService {
4188
5391
  name,
4189
5392
  owner: ownerInSourceMapping,
4190
5393
  descriptor,
4191
- signatureMode: kind === "method" && !descriptor ? "name-only" : undefined,
5394
+ // When we do have a descriptor this path is an exact lookup (the resolveMethodMappingExact
5395
+ // fast path is chosen instead whenever possible). findMapping's service-layer default is
5396
+ // "name-only" to match the public tool schema, so we must opt in to strict semantics here
5397
+ // to preserve the descriptor-aware overload selection this caller relies on.
5398
+ signatureMode: kind === "method"
5399
+ ? descriptor
5400
+ ? "exact"
5401
+ : "name-only"
5402
+ : undefined,
4192
5403
  sourceMapping: mapping,
4193
5404
  targetMapping: "obfuscated",
4194
5405
  sourcePriority
@@ -4200,6 +5411,33 @@ export class SourceService {
4200
5411
  descriptor: kind === "method" ? mapped.resolvedSymbol.descriptor ?? descriptor : undefined
4201
5412
  };
4202
5413
  }
5414
+ // resolveMethodMappingExact still rejects partial descriptor projections, so a
5415
+ // Mojang / Yarn method whose descriptor mixes a remapped Minecraft class with a JDK
5416
+ // type (e.g. `(L...ItemStack;Ljava/lang/String;)V`) bottoms out as unresolved /
5417
+ // mapping_unavailable here even though `findMapping` with signatureMode="exact" would
5418
+ // accept the partially projected descriptor. Fall back to `findMapping` before giving
5419
+ // up so downstream paths (access-widener remap, trace-symbol-lifecycle, signature
5420
+ // member remap) do not silently miss real methods.
5421
+ if (canResolveMethodExactly && (mapped.status === "not_found" || mapped.status === "mapping_unavailable")) {
5422
+ const fallbackMapped = await this.mappingService.findMapping({
5423
+ version,
5424
+ kind,
5425
+ name,
5426
+ owner: ownerInSourceMapping,
5427
+ descriptor,
5428
+ signatureMode: "exact",
5429
+ sourceMapping: mapping,
5430
+ targetMapping: "obfuscated",
5431
+ sourcePriority
5432
+ });
5433
+ warnings.push(...fallbackMapped.warnings);
5434
+ if (fallbackMapped.resolved && fallbackMapped.resolvedSymbol) {
5435
+ return {
5436
+ name: fallbackMapped.resolvedSymbol.name,
5437
+ descriptor: kind === "method" ? fallbackMapped.resolvedSymbol.descriptor ?? descriptor : undefined
5438
+ };
5439
+ }
5440
+ }
4203
5441
  warnings.push(`Could not map ${kind} "${name}" from ${mapping} to obfuscated.`);
4204
5442
  }
4205
5443
  catch (caughtError) {
@@ -4210,21 +5448,22 @@ export class SourceService {
4210
5448
  descriptor: kind === "method" ? descriptor : undefined
4211
5449
  };
4212
5450
  }
4213
- async remapSignatureMembers(members, kind, version, sourceMapping, targetMapping, sourcePriority, warnings) {
5451
+ async remapSignatureMembers(members, kind, version, sourceMapping, targetMapping, sourcePriority, warnings, projectPath) {
4214
5452
  const failedNames = new Set();
4215
5453
  if (sourceMapping === targetMapping) {
4216
5454
  return { members, failedNames };
4217
5455
  }
4218
5456
  // Build deduplicated lookup tables for member names and owner FQNs
4219
5457
  const memberKeyToRemapped = new Map();
5458
+ const memberDescriptorRemapped = new Map();
4220
5459
  const ownerToRemapped = new Map();
4221
5460
  for (const member of members) {
4222
5461
  const memberKey = `${member.ownerFqn}\0${member.name}\0${member.jvmDescriptor}`;
4223
5462
  if (!memberKeyToRemapped.has(memberKey)) {
4224
- memberKeyToRemapped.set(memberKey, member.name); // default = obfuscated name
5463
+ memberKeyToRemapped.set(memberKey, member.name); // default = source name
4225
5464
  }
4226
5465
  if (!ownerToRemapped.has(member.ownerFqn)) {
4227
- ownerToRemapped.set(member.ownerFqn, member.ownerFqn); // default = obfuscated FQN
5466
+ ownerToRemapped.set(member.ownerFqn, member.ownerFqn); // default = source FQN
4228
5467
  }
4229
5468
  }
4230
5469
  // Phase 1: Remap owner FQNs first (needed for member disambiguation)
@@ -4237,35 +5476,122 @@ export class SourceService {
4237
5476
  name: obfuscatedFqn,
4238
5477
  sourceMapping,
4239
5478
  targetMapping,
4240
- sourcePriority
5479
+ sourcePriority,
5480
+ projectPath
4241
5481
  });
4242
5482
  if (mapped.resolved && mapped.resolvedSymbol) {
4243
5483
  ownerToRemapped.set(obfuscatedFqn, mapped.resolvedSymbol.name);
4244
5484
  }
4245
5485
  }
4246
5486
  catch {
4247
- // keep obfuscated FQN as fallback
5487
+ // keep source FQN as fallback
4248
5488
  }
4249
5489
  }));
4250
- // Phase 2: Remap member names using resolved owners for disambiguation
5490
+ // Phase 1.5: Collect class references from descriptors and remap them
5491
+ const descriptorClassRefs = new Set();
5492
+ for (const member of members) {
5493
+ for (const match of member.jvmDescriptor.matchAll(/L([^;]+);/g)) {
5494
+ const dotFqn = match[1].replace(/\//g, ".");
5495
+ if (!ownerToRemapped.has(dotFqn)) {
5496
+ descriptorClassRefs.add(dotFqn);
5497
+ }
5498
+ }
5499
+ }
5500
+ if (descriptorClassRefs.size > 0) {
5501
+ const refs = [...descriptorClassRefs];
5502
+ for (const ref of refs) {
5503
+ ownerToRemapped.set(ref, ref); // default = source name
5504
+ }
5505
+ await Promise.all(refs.map(async (dotFqn) => {
5506
+ try {
5507
+ const mapped = await this.mappingService.findMapping({
5508
+ version,
5509
+ kind: "class",
5510
+ name: dotFqn,
5511
+ sourceMapping,
5512
+ targetMapping,
5513
+ sourcePriority,
5514
+ projectPath
5515
+ });
5516
+ if (mapped.resolved && mapped.resolvedSymbol) {
5517
+ ownerToRemapped.set(dotFqn, mapped.resolvedSymbol.name);
5518
+ }
5519
+ }
5520
+ catch {
5521
+ // keep source name as fallback
5522
+ }
5523
+ }));
5524
+ }
5525
+ // Build a class map for descriptor remapping (dot-FQN → dot-FQN)
5526
+ const classMap = new Map();
5527
+ for (const [src, tgt] of ownerToRemapped) {
5528
+ if (src !== tgt) {
5529
+ classMap.set(src, tgt);
5530
+ }
5531
+ }
5532
+ // Phase 2: Remap member names (and descriptors for methods) using resolved owners
5533
+ const canResolveMethodExactly = kind === "method" &&
5534
+ "resolveMethodMappingExact" in this.mappingService &&
5535
+ typeof this.mappingService.resolveMethodMappingExact === "function";
4251
5536
  const memberEntries = [...memberKeyToRemapped.entries()];
4252
- await Promise.all(memberEntries.map(async ([key, _obfuscatedName]) => {
5537
+ await Promise.all(memberEntries.map(async ([key, _sourceName]) => {
4253
5538
  const [ownerFqn, name, descriptor] = key.split("\0");
4254
5539
  try {
4255
5540
  const targetOwner = ownerToRemapped.get(ownerFqn) ?? ownerFqn;
5541
+ // For methods with descriptors, try exact resolution first
5542
+ if (canResolveMethodExactly && descriptor) {
5543
+ try {
5544
+ const exactResult = await this.mappingService.resolveMethodMappingExact({
5545
+ version,
5546
+ owner: ownerFqn,
5547
+ name: name,
5548
+ descriptor,
5549
+ sourceMapping,
5550
+ targetMapping,
5551
+ sourcePriority,
5552
+ projectPath
5553
+ });
5554
+ if (exactResult.resolved && exactResult.resolvedSymbol) {
5555
+ memberKeyToRemapped.set(key, exactResult.resolvedSymbol.name);
5556
+ if (exactResult.resolvedSymbol.descriptor) {
5557
+ memberDescriptorRemapped.set(key, exactResult.resolvedSymbol.descriptor);
5558
+ }
5559
+ return; // exact resolution succeeded
5560
+ }
5561
+ // Fall through to findMapping with descriptorHint
5562
+ }
5563
+ catch (exactError) {
5564
+ warnings.push(`Exact method resolution failed for "${name}" (falling back to name-based lookup): ${exactError instanceof Error ? exactError.message : String(exactError)}`);
5565
+ }
5566
+ }
5567
+ // Fallback: findMapping with descriptorHint for overload disambiguation
5568
+ const remappedDescriptorHint = kind === "method" && descriptor
5569
+ ? remapJvmDescriptor(descriptor, classMap)
5570
+ : undefined;
4256
5571
  const mapped = await this.mappingService.findMapping({
4257
5572
  version,
4258
5573
  kind,
4259
5574
  name,
4260
5575
  owner: ownerFqn,
4261
5576
  descriptor: kind === "method" ? descriptor : undefined,
5577
+ // Access-widener / access-transformer remap runs after validation has accepted
5578
+ // the descriptor as authoritative, so preserve exact overload matching even
5579
+ // though the descriptorHint path also exists for ambiguity fallback.
5580
+ signatureMode: kind === "method" && descriptor ? "exact" : undefined,
4262
5581
  sourceMapping,
4263
5582
  targetMapping,
4264
5583
  sourcePriority,
4265
- disambiguation: { ownerHint: targetOwner }
5584
+ projectPath,
5585
+ disambiguation: {
5586
+ ownerHint: targetOwner,
5587
+ descriptorHint: remappedDescriptorHint
5588
+ }
4266
5589
  });
4267
5590
  if (mapped.resolved && mapped.resolvedSymbol) {
4268
5591
  memberKeyToRemapped.set(key, mapped.resolvedSymbol.name);
5592
+ if (kind === "method" && mapped.resolvedSymbol.descriptor) {
5593
+ memberDescriptorRemapped.set(key, mapped.resolvedSymbol.descriptor);
5594
+ }
4269
5595
  }
4270
5596
  else if (mapped.status === "ambiguous" && mapped.candidates && mapped.candidates.length > 0) {
4271
5597
  // Disambiguate: filter by target owner and pick the best candidate
@@ -4293,13 +5619,20 @@ export class SourceService {
4293
5619
  failedNames.add(name);
4294
5620
  }
4295
5621
  }));
5622
+ const isField = kind === "field";
4296
5623
  return {
4297
5624
  members: members.map((member) => {
4298
5625
  const memberKey = `${member.ownerFqn}\0${member.name}\0${member.jvmDescriptor}`;
5626
+ const remappedName = memberKeyToRemapped.get(memberKey) ?? member.name;
5627
+ const remappedOwner = ownerToRemapped.get(member.ownerFqn) ?? member.ownerFqn;
5628
+ const remappedDescriptor = memberDescriptorRemapped.get(memberKey)
5629
+ ?? remapJvmDescriptor(member.jvmDescriptor, classMap);
4299
5630
  return {
4300
5631
  ...member,
4301
- name: memberKeyToRemapped.get(memberKey) ?? member.name,
4302
- ownerFqn: ownerToRemapped.get(member.ownerFqn) ?? member.ownerFqn
5632
+ name: remappedName,
5633
+ ownerFqn: remappedOwner,
5634
+ jvmDescriptor: remappedDescriptor,
5635
+ javaSignature: rebuildJavaSignature({ name: remappedName, ownerFqn: remappedOwner, accessFlags: member.accessFlags }, remappedDescriptor, isField)
4303
5636
  };
4304
5637
  }),
4305
5638
  failedNames
@@ -4326,6 +5659,7 @@ export class SourceService {
4326
5659
  toResolvedArtifact(artifact) {
4327
5660
  return {
4328
5661
  artifactId: artifact.artifactId,
5662
+ artifactAlias: artifact.alias,
4329
5663
  artifactSignature: artifact.artifactSignature ?? this.fallbackArtifactSignature(artifact.artifactId),
4330
5664
  origin: artifact.origin,
4331
5665
  binaryJarPath: artifact.binaryJarPath,
@@ -4348,6 +5682,7 @@ export class SourceService {
4348
5682
  const tx = this.db.transaction(() => {
4349
5683
  this.artifactsRepo.upsertArtifact({
4350
5684
  artifactId: resolved.artifactId,
5685
+ alias: resolved.artifactAlias,
4351
5686
  origin: resolved.origin,
4352
5687
  coordinate: resolved.coordinate,
4353
5688
  version: resolved.version,
@@ -4399,10 +5734,20 @@ export class SourceService {
4399
5734
  files = await this.loadFromSourceJar(resolved.sourceJarPath);
4400
5735
  }
4401
5736
  else if (resolved.binaryJarPath) {
5737
+ const decompileInputJarPath = await this.maybeRemapBinaryForMojang(resolved);
5738
+ // When the binary jar was remapped from obfuscated to mojang, swap the resolved
5739
+ // artifact's binaryJarPath to the remapped jar so downstream bytecode consumers
5740
+ // (getClassMembers, validateMixin) look up mojang names in the mojang jar — not
5741
+ // the original obfuscated jar. Persistence in upsertArtifact happens after this
5742
+ // function returns, so the swap reaches both the database row and the
5743
+ // resolveArtifact response.
5744
+ if (decompileInputJarPath !== resolved.binaryJarPath) {
5745
+ resolved.binaryJarPath = decompileInputJarPath;
5746
+ }
4402
5747
  const vineflowerPath = await resolveVineflowerJar(this.config.cacheDir, this.config.vineflowerJarPath);
4403
5748
  const decompileStartedAt = Date.now();
4404
5749
  try {
4405
- const decompileResult = await decompileBinaryJar(resolved.binaryJarPath, this.config.cacheDir, {
5750
+ const decompileResult = await decompileBinaryJar(decompileInputJarPath, this.config.cacheDir, {
4406
5751
  vineflowerJarPath: vineflowerPath,
4407
5752
  artifactIdCandidate: resolved.artifactId,
4408
5753
  timeoutMs: 120_000,
@@ -4504,6 +5849,30 @@ export class SourceService {
4504
5849
  meta
4505
5850
  });
4506
5851
  if (existing && reason === "already_current") {
5852
+ // Mojang binary-remap reconciliation on the warm cache hit path:
5853
+ // resolveSourceTargetInternal always returns the original binary jar
5854
+ // (resolver does not know about prior remap output), so without this
5855
+ // step a warm-cache resolve would return mappingApplied="mojang"
5856
+ // alongside binaryJarPath pointing at the obfuscated client jar.
5857
+ // maybeRemapBinaryForMojang short-circuits on a healthy cache hit
5858
+ // (existsSync + ZIP magic) and re-remaps when the cache is missing
5859
+ // or corrupted, so this also recovers from out-of-band cache loss.
5860
+ const transformChain = resolved.provenance?.transformChain ?? [];
5861
+ if (transformChain.includes("binary-remap:obf->mojang") && resolved.binaryJarPath) {
5862
+ const reconciledBinaryJarPath = await this.maybeRemapBinaryForMojang(resolved);
5863
+ if (reconciledBinaryJarPath !== resolved.binaryJarPath) {
5864
+ resolved.binaryJarPath = reconciledBinaryJarPath;
5865
+ }
5866
+ }
5867
+ // Backfill / rotate alias on the warm-cache path. Without this, schema-v4
5868
+ // migrated rows (alias=NULL) and rows whose alias parameters changed since
5869
+ // the last upsert would return an artifactAlias from resolveArtifact that
5870
+ // does not resolve back via getArtifact(alias), breaking the 3.1b lookup
5871
+ // contract. UNIQUE conflicts here are caller bugs (two distinct artifactIds
5872
+ // colliding on alias) and surface as DB errors rather than silent drift.
5873
+ if (resolved.artifactAlias && existing.alias !== resolved.artifactAlias) {
5874
+ this.artifactsRepo.setAlias(resolved.artifactId, resolved.artifactAlias);
5875
+ }
4507
5876
  this.metrics.recordArtifactCacheHit();
4508
5877
  const touchedAt = new Date().toISOString();
4509
5878
  this.artifactsRepo.touchArtifact(resolved.artifactId, touchedAt);
@@ -4519,6 +5888,164 @@ export class SourceService {
4519
5888
  await this.rebuildAndPersistArtifactIndex(resolved, reason === "already_current" ? "missing_meta" : reason);
4520
5889
  this.enforceCacheLimits();
4521
5890
  }
5891
+ /**
5892
+ * If the resolved artifact's transformChain promised an "obf -> mojang"
5893
+ * binary remap, run tiny-remapper now and return the remapped jar path.
5894
+ * Otherwise return the original binaryJarPath unchanged.
5895
+ *
5896
+ * Cache safety: writes to a per-attempt temp file then atomic-renames into
5897
+ * <cacheDir>/remapped/<artifactId>.jar. A per-target inflight Promise map
5898
+ * collapses concurrent calls so two simultaneous resolveArtifact calls for
5899
+ * the same artifactId share one tiny-remapper run instead of racing on the
5900
+ * same output path.
5901
+ */
5902
+ async maybeRemapBinaryForMojang(resolved) {
5903
+ const binaryJarPath = resolved.binaryJarPath;
5904
+ if (!binaryJarPath) {
5905
+ throw createError({
5906
+ code: ERROR_CODES.SOURCE_NOT_FOUND,
5907
+ message: "Cannot run binary remap: resolved artifact has no binary jar path.",
5908
+ details: { artifactId: resolved.artifactId }
5909
+ });
5910
+ }
5911
+ const transformChain = resolved.provenance?.transformChain ?? [];
5912
+ if (!transformChain.includes("binary-remap:obf->mojang")) {
5913
+ return binaryJarPath;
5914
+ }
5915
+ if (!resolved.version) {
5916
+ throw createError({
5917
+ code: ERROR_CODES.MAPPING_NOT_APPLIED,
5918
+ message: "Binary remap promised but artifact has no resolved Minecraft version.",
5919
+ details: {
5920
+ artifactId: resolved.artifactId,
5921
+ binaryJarPath,
5922
+ nextAction: "Use target.kind=\"version\" so the remap pipeline can locate Mojang mappings."
5923
+ }
5924
+ });
5925
+ }
5926
+ const remappedDir = join(this.config.cacheDir, "remapped");
5927
+ const remappedJarPath = join(remappedDir, `${resolved.artifactId}.jar`);
5928
+ if (existsSync(remappedJarPath)) {
5929
+ // Validate the cached jar is at least structurally a ZIP (`PK\x03\x04`) and
5930
+ // non-empty before reusing. If a prior atomic-rename window was interrupted
5931
+ // or the cache file was hand-edited, drop it and re-remap rather than
5932
+ // silently feeding a corrupt jar into Vineflower.
5933
+ if (await this.isUsableJarFile(remappedJarPath)) {
5934
+ await this.recordRemappedJarBytesFromDisk(resolved.artifactId, remappedJarPath);
5935
+ return remappedJarPath;
5936
+ }
5937
+ log("warn", "binary-remap.cache.evict-corrupt", {
5938
+ artifactId: resolved.artifactId,
5939
+ remappedJarPath
5940
+ });
5941
+ try {
5942
+ await unlink(remappedJarPath);
5943
+ }
5944
+ catch {
5945
+ // ignore: race with another process or already-deleted file.
5946
+ }
5947
+ this.releaseRemappedJarBytes(resolved.artifactId);
5948
+ }
5949
+ const inflight = this.inflightRemaps.get(remappedJarPath);
5950
+ if (inflight) {
5951
+ return inflight;
5952
+ }
5953
+ const remapPromise = this.runBinaryRemap({
5954
+ version: resolved.version,
5955
+ inputJar: binaryJarPath,
5956
+ remappedDir,
5957
+ remappedJarPath
5958
+ });
5959
+ this.inflightRemaps.set(remappedJarPath, remapPromise);
5960
+ try {
5961
+ const path = await remapPromise;
5962
+ await this.recordRemappedJarBytesFromDisk(resolved.artifactId, path);
5963
+ return path;
5964
+ }
5965
+ finally {
5966
+ this.inflightRemaps.delete(remappedJarPath);
5967
+ }
5968
+ }
5969
+ async recordRemappedJarBytesFromDisk(artifactId, path) {
5970
+ try {
5971
+ const fileStat = await stat(path);
5972
+ this.recordRemappedJarBytes(artifactId, fileStat.size);
5973
+ }
5974
+ catch {
5975
+ // best-effort: accounting will be rebuilt on the next refreshCacheMetrics.
5976
+ }
5977
+ }
5978
+ /**
5979
+ * Best-effort structural check that `path` is a non-empty file beginning with
5980
+ * the ZIP local-file-header magic (`50 4B 03 04`). Used to drop partial /
5981
+ * corrupt remap-cache entries before they reach Vineflower. False positives
5982
+ * are acceptable (Vineflower will surface a clearer error); false negatives
5983
+ * are not (a corrupt cache hit must be evicted).
5984
+ */
5985
+ async isUsableJarFile(path) {
5986
+ try {
5987
+ const stats = await stat(path);
5988
+ if (!stats.isFile() || stats.size < 4) {
5989
+ return false;
5990
+ }
5991
+ }
5992
+ catch {
5993
+ return false;
5994
+ }
5995
+ let handle;
5996
+ try {
5997
+ handle = await open(path, "r");
5998
+ const header = Buffer.alloc(4);
5999
+ const { bytesRead } = await handle.read(header, 0, 4, 0);
6000
+ return bytesRead === 4 && header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04;
6001
+ }
6002
+ catch {
6003
+ return false;
6004
+ }
6005
+ finally {
6006
+ await handle?.close().catch(() => undefined);
6007
+ }
6008
+ }
6009
+ async runBinaryRemap(input) {
6010
+ const tinyRemapperJarPath = await resolveTinyRemapperJar(this.config.cacheDir, this.config.tinyRemapperJarPath);
6011
+ const mojangTiny = await resolveMojangTinyFile(input.version, this.config);
6012
+ await mkdir(input.remappedDir, { recursive: true });
6013
+ const tempPath = `${input.remappedJarPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
6014
+ const remapStartedAt = Date.now();
6015
+ try {
6016
+ await remapJar(tinyRemapperJarPath, {
6017
+ inputJar: input.inputJar,
6018
+ outputJar: tempPath,
6019
+ mappingsFile: mojangTiny.path,
6020
+ fromNamespace: "obfuscated",
6021
+ toNamespace: "mojang",
6022
+ timeoutMs: this.config.remapTimeoutMs,
6023
+ maxMemoryMb: this.config.remapMaxMemoryMb
6024
+ });
6025
+ const tempStats = await stat(tempPath);
6026
+ if (tempStats.size === 0) {
6027
+ throw createError({
6028
+ code: ERROR_CODES.REMAP_FAILED,
6029
+ message: "tiny-remapper produced an empty output jar.",
6030
+ details: { inputJar: input.inputJar, tempPath }
6031
+ });
6032
+ }
6033
+ await rename(tempPath, input.remappedJarPath);
6034
+ return input.remappedJarPath;
6035
+ }
6036
+ catch (caughtError) {
6037
+ try {
6038
+ await unlink(tempPath);
6039
+ }
6040
+ catch {
6041
+ // tempPath may not exist if remapJar failed before writing anything; ignore.
6042
+ }
6043
+ throw caughtError;
6044
+ }
6045
+ finally {
6046
+ this.metrics.recordDuration("binary_remap_duration_ms", Date.now() - remapStartedAt);
6047
+ }
6048
+ }
4522
6049
  async loadFromSourceJar(sourceJarPath) {
4523
6050
  const files = [];
4524
6051
  for await (const entry of iterateJavaEntriesAsUtf8(sourceJarPath, this.config.maxContentBytes)) {
@@ -4534,13 +6061,55 @@ export class SourceService {
4534
6061
  hasAnyFiles(artifactId) {
4535
6062
  return this.filesRepo.listFiles(artifactId, { limit: 1 }).items.length > 0;
4536
6063
  }
6064
+ /**
6065
+ * Best-effort cleanup of `<cacheDir>/remapped/<artifactId>.jar` written by
6066
+ * `maybeRemapBinaryForMojang`. Called from cache-eviction paths so the
6067
+ * Mojang-remapped binary jar does not outlive the artifact row that owns it.
6068
+ * Also releases the jar's bytes from `cacheTotalContentBytes`. Errors are
6069
+ * swallowed: orphaned jars remain visible to `manage-cache` under the
6070
+ * `binary-remap` cache kind and can be reclaimed there.
6071
+ */
6072
+ unlinkRemappedJarForArtifact(artifactId) {
6073
+ this.releaseRemappedJarBytes(artifactId);
6074
+ const remappedJarPath = join(this.config.cacheDir, "remapped", `${artifactId}.jar`);
6075
+ try {
6076
+ if (existsSync(remappedJarPath)) {
6077
+ unlinkSync(remappedJarPath);
6078
+ }
6079
+ }
6080
+ catch {
6081
+ // ignore: orphaned jar is still reclaimable via manage-cache binary-remap kind.
6082
+ }
6083
+ }
6084
+ /**
6085
+ * Add the remapped jar's on-disk size to `cacheTotalContentBytes` so the
6086
+ * `enforceCacheLimits` byte gate sees the jar before deciding to evict.
6087
+ * Without this, a Mojang-remapped client jar (tens of MB) can accumulate
6088
+ * silently while the indexed-source byte total stays below `maxCacheBytes`.
6089
+ */
6090
+ recordRemappedJarBytes(artifactId, sizeBytes) {
6091
+ const normalized = Math.max(0, Math.trunc(sizeBytes));
6092
+ const existing = this.remappedJarBytes.get(artifactId) ?? 0;
6093
+ this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing + normalized);
6094
+ this.remappedJarBytes.set(artifactId, normalized);
6095
+ this.publishCacheMetrics();
6096
+ }
6097
+ releaseRemappedJarBytes(artifactId) {
6098
+ const existing = this.remappedJarBytes.get(artifactId);
6099
+ if (!existing) {
6100
+ return;
6101
+ }
6102
+ this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing);
6103
+ this.remappedJarBytes.delete(artifactId);
6104
+ this.publishCacheMetrics();
6105
+ }
4537
6106
  enforceCacheLimits() {
4538
- let artifactCount = this.cacheMetricsState.entries;
4539
- let totalBytes = this.cacheMetricsState.totalContentBytes;
6107
+ let artifactCount = this.lru.size;
6108
+ let totalBytes = this.cacheTotalContentBytes;
4540
6109
  if (artifactCount <= this.config.maxArtifacts && totalBytes <= this.config.maxCacheBytes) {
4541
6110
  return;
4542
6111
  }
4543
- const candidates = [...this.cacheMetricsState.lru];
6112
+ const candidates = this.lru.toArray();
4544
6113
  for (const candidate of candidates) {
4545
6114
  const shouldEvict = artifactCount > this.config.maxArtifacts || totalBytes > this.config.maxCacheBytes;
4546
6115
  if (!shouldEvict || artifactCount <= 1) {
@@ -4548,17 +6117,19 @@ export class SourceService {
4548
6117
  }
4549
6118
  const artifactCountBefore = artifactCount;
4550
6119
  const totalBytesBefore = totalBytes;
4551
- this.filesRepo.deleteFilesForArtifact(candidate.artifactId);
4552
- this.artifactsRepo.deleteArtifact(candidate.artifactId);
4553
- this.removeCacheMetrics(candidate.artifactId, false);
6120
+ const remappedBytesForCandidate = this.remappedJarBytes.get(candidate.key) ?? 0;
6121
+ this.filesRepo.deleteFilesForArtifact(candidate.key);
6122
+ this.artifactsRepo.deleteArtifact(candidate.key);
6123
+ this.unlinkRemappedJarForArtifact(candidate.key);
6124
+ this.removeCacheMetrics(candidate.key, false);
4554
6125
  artifactCount = Math.max(0, artifactCount - 1);
4555
- totalBytes = Math.max(0, totalBytes - candidate.totalContentBytes);
6126
+ totalBytes = Math.max(0, totalBytes - candidate.value.totalContentBytes - remappedBytesForCandidate);
4556
6127
  this.metrics.recordCacheEviction();
4557
6128
  log("warn", "cache.evict", {
4558
- artifactId: candidate.artifactId,
6129
+ artifactId: candidate.key,
4559
6130
  artifactCountBefore,
4560
6131
  totalBytesBefore,
4561
- artifactBytes: candidate.totalContentBytes
6132
+ artifactBytes: candidate.value.totalContentBytes + remappedBytesForCandidate
4562
6133
  });
4563
6134
  }
4564
6135
  this.publishCacheMetrics();
@@ -4566,82 +6137,129 @@ export class SourceService {
4566
6137
  refreshCacheMetrics() {
4567
6138
  const cacheEntries = this.artifactsRepo.countArtifacts();
4568
6139
  const totalContentBytes = this.artifactsRepo.totalContentBytes();
4569
- const lruAccounting = this.artifactsRepo
4570
- .listArtifactsByLruWithContentBytes(Math.max(cacheEntries, 1))
4571
- .map((row) => ({
4572
- artifactId: row.artifactId,
4573
- totalContentBytes: row.totalContentBytes,
4574
- updatedAt: row.updatedAt
4575
- }));
4576
- this.cacheMetricsState = {
4577
- entries: cacheEntries,
4578
- totalContentBytes,
4579
- lru: lruAccounting
4580
- };
6140
+ const lruAccounting = this.artifactsRepo.listArtifactsByLruWithContentBytes(Math.max(cacheEntries, 1));
6141
+ this.lru.clear();
6142
+ for (const row of lruAccounting) {
6143
+ this.lru.upsert(row.artifactId, {
6144
+ totalContentBytes: row.totalContentBytes,
6145
+ updatedAt: row.updatedAt
6146
+ });
6147
+ }
6148
+ this.remappedJarBytes.clear();
6149
+ let remappedTotal = 0;
6150
+ const remappedDir = join(this.config.cacheDir, "remapped");
6151
+ if (existsSync(remappedDir)) {
6152
+ // Only count remapped jars whose owning artifact is still in the LRU set.
6153
+ // Orphaned jars (artifact deleted, prior unlink lost a race, externally
6154
+ // placed) stay visible to manage-cache under the `binary-remap` kind
6155
+ // for prune, but must not be folded into `cacheTotalContentBytes` here:
6156
+ // enforceCacheLimits cannot evict them, so counting their bytes would
6157
+ // force unrelated live artifacts to be evicted to chase orphan bytes.
6158
+ const liveArtifactIds = new Set(this.lru.toArray().map((entry) => entry.key));
6159
+ try {
6160
+ for (const entry of readdirSync(remappedDir, { withFileTypes: true })) {
6161
+ if (!entry.isFile() || !entry.name.endsWith(".jar")) {
6162
+ continue;
6163
+ }
6164
+ const artifactId = entry.name.slice(0, -".jar".length);
6165
+ if (!liveArtifactIds.has(artifactId)) {
6166
+ continue;
6167
+ }
6168
+ try {
6169
+ const fileStat = statSync(join(remappedDir, entry.name));
6170
+ const size = Math.max(0, Math.trunc(fileStat.size));
6171
+ this.remappedJarBytes.set(artifactId, size);
6172
+ remappedTotal += size;
6173
+ }
6174
+ catch {
6175
+ // ignore stat failure on a single jar; total stays best-effort.
6176
+ }
6177
+ }
6178
+ }
6179
+ catch {
6180
+ // ignore listing failure; remapped accounting stays empty until next remap.
6181
+ }
6182
+ }
6183
+ this.cacheTotalContentBytes = totalContentBytes + remappedTotal;
4581
6184
  this.publishCacheMetrics();
4582
6185
  }
4583
6186
  touchCacheMetrics(artifactId, updatedAt) {
4584
- const existingIndex = this.cacheMetricsState.lru.findIndex((row) => row.artifactId === artifactId);
4585
- if (existingIndex < 0) {
4586
- this.refreshCacheMetrics();
4587
- return;
4588
- }
4589
- const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
4590
- if (!existing) {
6187
+ const entry = this.lru.touch(artifactId);
6188
+ if (!entry) {
4591
6189
  this.refreshCacheMetrics();
4592
6190
  return;
4593
6191
  }
4594
- existing.updatedAt = updatedAt;
4595
- this.cacheMetricsState.lru.push(existing);
6192
+ entry.updatedAt = updatedAt;
4596
6193
  this.publishCacheMetrics();
4597
6194
  }
4598
6195
  upsertCacheMetrics(artifactId, totalContentBytes, updatedAt) {
4599
6196
  const normalizedBytes = Math.max(0, Math.trunc(totalContentBytes));
4600
- const existingIndex = this.cacheMetricsState.lru.findIndex((row) => row.artifactId === artifactId);
4601
- if (existingIndex >= 0) {
4602
- const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
4603
- if (!existing) {
4604
- this.refreshCacheMetrics();
4605
- return;
4606
- }
4607
- this.cacheMetricsState.totalContentBytes = Math.max(0, this.cacheMetricsState.totalContentBytes - existing.totalContentBytes + normalizedBytes);
4608
- existing.totalContentBytes = normalizedBytes;
4609
- existing.updatedAt = updatedAt;
4610
- this.cacheMetricsState.lru.push(existing);
6197
+ const existing = this.lru.remove(artifactId);
6198
+ if (existing) {
6199
+ this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing.totalContentBytes + normalizedBytes);
4611
6200
  }
4612
6201
  else {
4613
- this.cacheMetricsState.entries += 1;
4614
- this.cacheMetricsState.totalContentBytes += normalizedBytes;
4615
- this.cacheMetricsState.lru.push({
4616
- artifactId,
4617
- totalContentBytes: normalizedBytes,
4618
- updatedAt
4619
- });
6202
+ this.cacheTotalContentBytes += normalizedBytes;
4620
6203
  }
4621
- this.cacheMetricsState.entries = this.cacheMetricsState.lru.length;
6204
+ this.lru.upsert(artifactId, { totalContentBytes: normalizedBytes, updatedAt });
4622
6205
  this.publishCacheMetrics();
4623
6206
  }
4624
6207
  removeCacheMetrics(artifactId, publish = true) {
4625
- const existingIndex = this.cacheMetricsState.lru.findIndex((row) => row.artifactId === artifactId);
4626
- if (existingIndex < 0) {
4627
- this.refreshCacheMetrics();
4628
- return;
4629
- }
4630
- const [existing] = this.cacheMetricsState.lru.splice(existingIndex, 1);
6208
+ const existing = this.lru.remove(artifactId);
4631
6209
  if (!existing) {
4632
6210
  this.refreshCacheMetrics();
4633
6211
  return;
4634
6212
  }
4635
- this.cacheMetricsState.entries = this.cacheMetricsState.lru.length;
4636
- this.cacheMetricsState.totalContentBytes = Math.max(0, this.cacheMetricsState.totalContentBytes - existing.totalContentBytes);
6213
+ this.cacheTotalContentBytes = Math.max(0, this.cacheTotalContentBytes - existing.totalContentBytes);
4637
6214
  if (publish) {
4638
6215
  this.publishCacheMetrics();
4639
6216
  }
4640
6217
  }
4641
6218
  publishCacheMetrics() {
4642
- this.metrics.setCacheEntries(this.cacheMetricsState.entries);
4643
- this.metrics.setCacheTotalContentBytes(this.cacheMetricsState.totalContentBytes);
4644
- this.metrics.setCacheArtifactByteAccountingRef(this.cacheMetricsState.lru);
6219
+ this.metrics.setCacheEntries(this.lru.size);
6220
+ this.metrics.setCacheTotalContentBytes(this.cacheTotalContentBytes);
6221
+ }
6222
+ snapshotLruAccounting() {
6223
+ this.metrics.setCacheArtifactByteAccountingRef(this.lru.toArray().map(({ key, value }) => ({
6224
+ artifactId: key,
6225
+ totalContentBytes: value.totalContentBytes,
6226
+ updatedAt: value.updatedAt
6227
+ })));
6228
+ }
6229
+ }
6230
+ function remapJvmDescriptor(descriptor, classMap) {
6231
+ if (classMap.size === 0) {
6232
+ return descriptor;
6233
+ }
6234
+ return descriptor.replace(/L([^;]+);/g, (match, ref) => {
6235
+ const dotFqn = ref.replace(/\//g, ".");
6236
+ const remapped = classMap.get(dotFqn);
6237
+ return remapped ? `L${remapped.replace(/\./g, "/")};` : match;
6238
+ });
6239
+ }
6240
+ function rebuildJavaSignature(member, remappedDescriptor, isField) {
6241
+ const modifiers = modifierPrefix(member.accessFlags, isField ? "field" : "method");
6242
+ const prefix = modifiers ? `${modifiers} ` : "";
6243
+ if (isField) {
6244
+ try {
6245
+ const { type } = parseFieldType(remappedDescriptor, 0, { allowVoid: false });
6246
+ return `${prefix}${type} ${member.name}`.trim();
6247
+ }
6248
+ catch {
6249
+ return `${prefix}${member.name}`.trim();
6250
+ }
6251
+ }
6252
+ try {
6253
+ const { args, returnType } = parseMethodDescriptor(remappedDescriptor);
6254
+ const argStr = args.join(", ");
6255
+ if (member.name === "<init>") {
6256
+ const ownerSimple = member.ownerFqn.split(".").pop();
6257
+ return `${prefix}${ownerSimple}(${argStr})`.trim();
6258
+ }
6259
+ return `${prefix}${returnType} ${member.name}(${argStr})`.trim();
6260
+ }
6261
+ catch {
6262
+ return `${prefix}${member.name}`.trim();
4645
6263
  }
4646
6264
  }
4647
6265
  //# sourceMappingURL=source-service.js.map