@adhisang/minecraft-modding-mcp 6.3.0 → 7.0.0-rc.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 (93) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +13 -3
  3. package/dist/cache-policy.d.ts +71 -0
  4. package/dist/cache-policy.js +83 -0
  5. package/dist/cache-registry.js +6 -6
  6. package/dist/cli.js +74 -3
  7. package/dist/compat-stdio-transport.d.ts +1 -1
  8. package/dist/compat-stdio-transport.js +13 -1
  9. package/dist/config.d.ts +3 -0
  10. package/dist/config.js +8 -2
  11. package/dist/decompiler/vineflower.d.ts +1 -0
  12. package/dist/decompiler/vineflower.js +8 -5
  13. package/dist/entry-tools/analyze-mod-service.d.ts +70 -136
  14. package/dist/entry-tools/analyze-symbol-service.d.ts +112 -150
  15. package/dist/entry-tools/compare-minecraft-service.d.ts +59 -145
  16. package/dist/entry-tools/entry-tool-schema.d.ts +38 -4
  17. package/dist/entry-tools/entry-tool-schema.js +4 -1
  18. package/dist/entry-tools/inspect-minecraft/internal.d.ts +235 -799
  19. package/dist/entry-tools/inspect-minecraft/internal.js +50 -13
  20. package/dist/entry-tools/inspect-minecraft-service.d.ts +372 -1736
  21. package/dist/entry-tools/manage-cache-service.d.ts +81 -91
  22. package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
  23. package/dist/entry-tools/validate-project-service.d.ts +164 -592
  24. package/dist/entry-tools/verify-mixin-target-service.d.ts +3 -19
  25. package/dist/era-classifier.d.ts +161 -0
  26. package/dist/era-classifier.js +292 -0
  27. package/dist/error-mapping.js +9 -2
  28. package/dist/index.d.ts +42 -4
  29. package/dist/index.js +636 -473
  30. package/dist/java-process.d.ts +2 -0
  31. package/dist/java-process.js +22 -2
  32. package/dist/json-rpc-framing.d.ts +77 -1
  33. package/dist/json-rpc-framing.js +249 -13
  34. package/dist/mapping/loaders/tiny-loom-selection.d.ts +88 -0
  35. package/dist/mapping/loaders/tiny-loom-selection.js +223 -0
  36. package/dist/mapping/loaders/tiny-loom.js +45 -33
  37. package/dist/mapping/loaders/tiny-maven.js +6 -11
  38. package/dist/mapping/parsers/tiny.d.ts +57 -0
  39. package/dist/mapping/parsers/tiny.js +99 -22
  40. package/dist/mapping-service.d.ts +19 -0
  41. package/dist/mapping-service.js +93 -9
  42. package/dist/mcp-helpers.d.ts +19 -2
  43. package/dist/mcp-helpers.js +48 -6
  44. package/dist/minecraft-explorer-service.d.ts +1 -1
  45. package/dist/mixin/types.d.ts +8 -0
  46. package/dist/mod-analyzer.js +7 -7
  47. package/dist/mod-decompile-service.js +1 -0
  48. package/dist/nbt/java-nbt-codec.js +12 -2
  49. package/dist/nbt/json-patch.js +14 -3
  50. package/dist/nbt/pipeline.js +40 -3
  51. package/dist/nbt/typed-json.js +26 -1
  52. package/dist/registration-adapter.d.ts +32 -0
  53. package/dist/registration-adapter.js +52 -0
  54. package/dist/request-context.d.ts +7 -0
  55. package/dist/request-context.js +9 -0
  56. package/dist/resources.d.ts +1 -1
  57. package/dist/resources.js +25 -19
  58. package/dist/server-identity.d.ts +27 -0
  59. package/dist/server-identity.js +26 -0
  60. package/dist/source/access-validate.js +53 -0
  61. package/dist/source/artifact-resolver.d.ts +69 -1
  62. package/dist/source/artifact-resolver.js +215 -14
  63. package/dist/source/class-source.d.ts +21 -0
  64. package/dist/source/class-source.js +125 -28
  65. package/dist/source/did-you-mean.d.ts +12 -1
  66. package/dist/source/did-you-mean.js +6 -2
  67. package/dist/source/file-access.js +150 -46
  68. package/dist/source/indexer.js +1 -0
  69. package/dist/source/shared-utils.d.ts +21 -0
  70. package/dist/source/shared-utils.js +23 -0
  71. package/dist/source-service.d.ts +11 -0
  72. package/dist/stdio-supervisor.d.ts +357 -2
  73. package/dist/stdio-supervisor.js +1031 -80
  74. package/dist/storage/db.d.ts +2 -1
  75. package/dist/storage/db.js +15 -8
  76. package/dist/synthetic-decorator.d.ts +24 -0
  77. package/dist/synthetic-decorator.js +48 -0
  78. package/dist/tool-guidance.d.ts +17 -1
  79. package/dist/tool-guidance.js +309 -11
  80. package/dist/tool-schema-registry.d.ts +2 -0
  81. package/dist/tool-schema-registry.js +4 -0
  82. package/dist/tool-schemas.d.ts +2212 -3919
  83. package/dist/tool-schemas.js +33 -7
  84. package/dist/types.d.ts +35 -0
  85. package/dist/v1-parity-schemas.d.ts +7 -0
  86. package/dist/v1-parity-schemas.js +5584 -0
  87. package/dist/version-diff-service.d.ts +33 -0
  88. package/dist/version-diff-service.js +148 -3
  89. package/dist/version-service.js +36 -14
  90. package/dist/warning-details.js +18 -1
  91. package/docs/README-ja.md +5 -3
  92. package/docs/tool-reference.md +194 -19
  93. package/package.json +8 -5
@@ -14,6 +14,7 @@ import { resolveTinyRemapperJar } from "../tiny-remapper-resolver.js";
14
14
  import { isUnobfuscatedVersion } from "../version-service.js";
15
15
  import { dedupeQualityFlags, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
16
16
  const VERSION_TOKEN_REGEX_CACHE = new Map();
17
+ const LOADER_VERSION_TOKEN_REGEX_CACHE = new Map();
17
18
  const MAX_HELPER_REGEX_CACHE = 128;
18
19
  function rememberCachedRegex(cache, key, regex) {
19
20
  if (cache.size >= MAX_HELPER_REGEX_CACHE) {
@@ -57,6 +58,97 @@ export function hasExactVersionToken(path, version) {
57
58
  ?? rememberCachedRegex(VERSION_TOKEN_REGEX_CACHE, normalizedVersion, new RegExp(`(^|[^0-9a-z])${escapeRegexLiteral(normalizedVersion)}(?![0-9a-z]|\\.[0-9])`, "i"));
58
59
  return pattern.test(normalizedPath);
59
60
  }
61
+ /**
62
+ * True when a path carries the NeoForge/Forge loader version that corresponds
63
+ * 1:1 to `mcVersion`.
64
+ *
65
+ * ModDevGradle names every artifact after the LOADER version and never after
66
+ * Minecraft: MC 1.21.11 produces `build/moddev/artifacts/neoforge-21.11.38-beta-merged.jar`.
67
+ * A plain `hasExactVersionToken(path, "1.21.11")` therefore rejected every
68
+ * artifact of the canonical NeoForge workspace, which made
69
+ * validate-access-transformer unusable there.
70
+ *
71
+ * NeoForge derives its version from Minecraft as `<minor>.<patch>.<build>`, so
72
+ * `1.21.11` -> `21.11.<build>` and `1.21` -> `21.0.<build>`. The trailing dot
73
+ * before the build number keeps `21.1.` from matching `21.10.5`.
74
+ */
75
+ export function hasLoaderRuntimeVersionToken(path, mcVersion) {
76
+ const match = /^1\.(\d+)(?:\.(\d+))?$/.exec(mcVersion.trim());
77
+ if (!match) {
78
+ return false;
79
+ }
80
+ const minor = match[1];
81
+ const patch = match[2] ?? "0";
82
+ const normalizedPath = normalizePathStyle(path).toLowerCase();
83
+ const cacheKey = `loader:${minor}.${patch}`;
84
+ const pattern = LOADER_VERSION_TOKEN_REGEX_CACHE.get(cacheKey)
85
+ ?? rememberCachedRegex(LOADER_VERSION_TOKEN_REGEX_CACHE, cacheKey, new RegExp(`(^|[^0-9a-z.])${minor}\\.${patch}\\.\\d`, "i"));
86
+ return pattern.test(normalizedPath);
87
+ }
88
+ const RUNTIME_JAR_VERSION_REGEX = /(?:^|[^0-9.])(1\.\d+(?:\.\d+)?)(?![0-9.])/;
89
+ /**
90
+ * Minecraft version a runtime jar path carries, or undefined when the path
91
+ * names none. Loom lays its cache out as
92
+ * `<gradle>/caches/fabric-loom/<mcVersion>/...`, so the first `1.x[.y]` token
93
+ * of the path is the version the jar was built for.
94
+ */
95
+ export function inferRuntimeJarMinecraftVersion(path) {
96
+ return RUNTIME_JAR_VERSION_REGEX.exec(normalizePathStyle(path))?.[1];
97
+ }
98
+ /**
99
+ * Loader a runtime jar belongs to, read from its path.
100
+ *
101
+ * A Loom cache holds NeoForge-patched jars under a `/neoforge/` segment
102
+ * (`caches/fabric-loom/1.21.10/neoforge/21.10.50-beta/minecraft-merged-mojang-at-patched.jar`).
103
+ * Serving one of those to a Fabric workspace silently validated a Fabric access
104
+ * widener against NeoForge bytecode, so the loader has to travel with the jar.
105
+ */
106
+ export function inferRuntimeJarLoader(path) {
107
+ const lower = normalizePathStyle(path).toLowerCase();
108
+ if (lower.includes("neoforge") || lower.includes("neoform") || lower.includes("moddev")) {
109
+ return "neoforge";
110
+ }
111
+ if (/(^|[/\-_])forge([/\-_.]|$)/.test(lower) || lower.includes("forge_gradle") || lower.includes("srg")) {
112
+ return "forge";
113
+ }
114
+ if (lower.includes("fabric-loom") || lower.includes("loom-cache") || lower.includes("intermediary")) {
115
+ return "fabric";
116
+ }
117
+ return "unknown";
118
+ }
119
+ /**
120
+ * Fills in the truthful version/loader half of a runtime provenance record.
121
+ *
122
+ * `version` becomes the version the SERVED jar carries; the caller's original
123
+ * request is preserved under `requestedVersion` and flagged. A served loader
124
+ * that contradicts a KNOWN expected loader is flagged too — never silently
125
+ * dropped.
126
+ */
127
+ export function describeServedRuntimeJar(input) {
128
+ const notes = [];
129
+ const servedVersion = inferRuntimeJarMinecraftVersion(input.jarPath);
130
+ const versionApproximated = servedVersion !== undefined && servedVersion !== input.requestedVersion.trim();
131
+ const servedLoader = inferRuntimeJarLoader(input.jarPath);
132
+ const expectedLoader = input.expectedLoader;
133
+ const loaderMismatch = expectedLoader !== undefined &&
134
+ expectedLoader !== "unknown" &&
135
+ servedLoader !== "unknown" &&
136
+ servedLoader !== expectedLoader;
137
+ if (versionApproximated) {
138
+ notes.push(`Runtime jar is Minecraft ${servedVersion}, not the requested ${input.requestedVersion}; results are approximate.`);
139
+ }
140
+ if (loaderMismatch) {
141
+ notes.push(`Runtime jar is a ${servedLoader} artifact while the workspace is ${expectedLoader}; its bytecode differs from the ${expectedLoader} runtime.`);
142
+ }
143
+ return {
144
+ version: versionApproximated && servedVersion ? servedVersion : input.requestedVersion,
145
+ ...(versionApproximated ? { requestedVersion: input.requestedVersion, versionApproximated: true } : {}),
146
+ servedLoader,
147
+ ...(expectedLoader ? { expectedLoader } : {}),
148
+ ...(loaderMismatch ? { loaderMismatch: true } : {}),
149
+ notes
150
+ };
151
+ }
60
152
  function inferMergedRuntimeNamespaceHint(path) {
61
153
  const normalizedPath = normalizePathStyle(path).toLowerCase();
62
154
  if (normalizedPath.includes("merged-intermediary-v2") ||
@@ -407,6 +499,11 @@ export async function discoverAccessTransformerRuntimeCandidates(_svc, input) {
407
499
  const normalizedProjectPathLower = normalizedProjectPath
408
500
  ? normalizePathStyle(normalizedProjectPath).toLowerCase()
409
501
  : undefined;
502
+ // A jar inside the caller's own build directory belongs to the version that
503
+ // workspace declares, whatever its filename says.
504
+ const projectAnchorAllowed = normalizedProjectPathLower !== undefined &&
505
+ input.projectMinecraftVersion !== undefined &&
506
+ input.projectMinecraftVersion.trim() === input.version.trim();
410
507
  const searchRoots = buildLoaderRuntimeSearchRoots({
411
508
  projectPath: normalizedProjectPath,
412
509
  gradleUserHome: input.gradleUserHome
@@ -449,7 +546,17 @@ export async function discoverAccessTransformerRuntimeCandidates(_svc, input) {
449
546
  }
450
547
  seen.add(normalizedPath);
451
548
  const lower = normalizedPath.toLowerCase();
452
- if (!hasExactVersionToken(normalizedPath, input.version)) {
549
+ const insideProject = normalizedProjectPathLower !== undefined && lower.startsWith(normalizedProjectPathLower);
550
+ // Positive version evidence is REQUIRED; the three forms are ranked so a
551
+ // verbatim Minecraft version always beats an inferred one.
552
+ const versionEvidence = hasExactVersionToken(normalizedPath, input.version)
553
+ ? "exact-token"
554
+ : hasLoaderRuntimeVersionToken(normalizedPath, input.version)
555
+ ? "loader-token"
556
+ : projectAnchorAllowed && insideProject
557
+ ? "project-anchored"
558
+ : undefined;
559
+ if (!versionEvidence) {
453
560
  continue;
454
561
  }
455
562
  const looksMerged = lower.includes("merged");
@@ -457,6 +564,11 @@ export async function discoverAccessTransformerRuntimeCandidates(_svc, input) {
457
564
  const looksForge = lower.includes("forge");
458
565
  const looksNeoForge = lower.includes("neoforge") || lower.includes("moddev") || lower.includes("neoform");
459
566
  const looksPatchedRuntime = lower.includes("patched") || lower.includes("client-extra") || lower.includes("joined");
567
+ // client-extra / *-minecraft-resources jars carry resources ONLY, so they
568
+ // can never answer a class lookup. They stay selectable (a workspace that
569
+ // has nothing else still gets an answer) but must lose to any real
570
+ // classes jar.
571
+ const looksResourcesOnly = lower.includes("client-extra") || lower.includes("minecraft-resources");
460
572
  const appliedScope = looksMerged
461
573
  ? "merged"
462
574
  : "loader";
@@ -470,18 +582,21 @@ export async function discoverAccessTransformerRuntimeCandidates(_svc, input) {
470
582
  continue;
471
583
  }
472
584
  const score = 10_000 +
473
- (normalizedProjectPathLower && lower.startsWith(normalizedProjectPathLower) ? 4_000 : 0) +
585
+ (versionEvidence === "exact-token" ? 5_000 : versionEvidence === "loader-token" ? 3_500 : 3_000) +
586
+ (insideProject ? 4_000 : 0) +
474
587
  (looksPatchedRuntime ? 3_000 : 0) +
475
588
  (looksSrg ? 2_500 : 0) +
476
589
  (input.loader === "forge" && looksForge ? 1_500 : 0) +
477
590
  (input.loader === "neoforge" && looksNeoForge ? 1_500 : 0) +
478
591
  (input.requestedScope === appliedScope ? 1_000 : 0) +
479
- (looksMerged ? -500 : 0);
592
+ (looksMerged ? -500 : 0) +
593
+ (looksResourcesOnly ? -6_000 : 0);
480
594
  candidates.push({
481
595
  jarPath: normalizedPath,
482
596
  score,
483
597
  appliedScope,
484
- origin: "local-jar"
598
+ origin: "local-jar",
599
+ versionEvidence
485
600
  });
486
601
  }
487
602
  }
@@ -504,6 +619,9 @@ export async function resolveAccessWidenerRuntimeArtifact(svc, input) {
504
619
  const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
505
620
  version = detected ?? version;
506
621
  }
622
+ const expectedLoader = normalizedProjectPath
623
+ ? await detectWorkspaceRuntimeLoader(svc, normalizedProjectPath)
624
+ : undefined;
507
625
  const requestedScope = input.scope ?? (normalizedProjectPath ? "loader" : "vanilla");
508
626
  if (requestedScope === "vanilla") {
509
627
  const versionJar = await svc.versionService.resolveVersionJar(version);
@@ -574,9 +692,21 @@ export async function resolveAccessWidenerRuntimeArtifact(svc, input) {
574
692
  notes.push(...detection.warnings);
575
693
  }
576
694
  }
695
+ // Provenance must describe the jar that was SERVED, never the request.
696
+ const served = describeServedRuntimeJar({
697
+ jarPath: discovery.selected.jarPath,
698
+ requestedVersion: version,
699
+ expectedLoader
700
+ });
701
+ notes.push(...served.notes);
577
702
  return {
578
- version,
703
+ version: served.version,
579
704
  jarPath: discovery.selected.jarPath,
705
+ ...(served.requestedVersion ? { requestedVersion: served.requestedVersion } : {}),
706
+ ...(served.versionApproximated ? { versionApproximated: true } : {}),
707
+ servedLoader: served.servedLoader,
708
+ ...(served.expectedLoader ? { expectedLoader: served.expectedLoader } : {}),
709
+ ...(served.loaderMismatch ? { loaderMismatch: true } : {}),
580
710
  requestedScope,
581
711
  appliedScope,
582
712
  requestedMapping: input.awNamespace,
@@ -586,6 +716,28 @@ export async function resolveAccessWidenerRuntimeArtifact(svc, input) {
586
716
  scopeFallback
587
717
  };
588
718
  }
719
+ /**
720
+ * Loader a workspace declares, normalized onto {@link RuntimeLoader}. Quilt is
721
+ * Fabric-compatible for runtime-jar purposes; anything undetected stays
722
+ * "unknown" so no mismatch is ever asserted on a guess.
723
+ */
724
+ async function detectWorkspaceRuntimeLoader(svc, projectPath) {
725
+ const detection = await svc.workspaceMappingService.detectProjectLoader(projectPath);
726
+ if (!detection.resolved) {
727
+ return "unknown";
728
+ }
729
+ switch (detection.loader) {
730
+ case "fabric":
731
+ case "quilt":
732
+ return "fabric";
733
+ case "forge":
734
+ return "forge";
735
+ case "neoforge":
736
+ return "neoforge";
737
+ default:
738
+ return "unknown";
739
+ }
740
+ }
589
741
  export async function resolveAccessTransformerNamespace(svc, input) {
590
742
  const explicit = normalizeAccessTransformerNamespace(input.atNamespace);
591
743
  if (explicit) {
@@ -622,9 +774,15 @@ export async function resolveAccessTransformerNamespace(svc, input) {
622
774
  export async function resolveAccessTransformerRuntimeArtifact(svc, input) {
623
775
  const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
624
776
  let version = input.version;
625
- if (input.preferProjectVersion && normalizedProjectPath) {
626
- const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
627
- version = detected ?? version;
777
+ // The workspace's own declared version is read whenever a project is given:
778
+ // preferProjectVersion decides whether it OVERRIDES the requested version,
779
+ // but discovery needs it either way to anchor project-local artifacts whose
780
+ // filenames carry only a loader version.
781
+ const projectMinecraftVersion = normalizedProjectPath
782
+ ? await svc.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath)
783
+ : undefined;
784
+ if (input.preferProjectVersion && projectMinecraftVersion) {
785
+ version = projectMinecraftVersion;
628
786
  }
629
787
  const requestedScope = input.scope ?? (normalizedProjectPath ? "loader" : "vanilla");
630
788
  if (requestedScope === "vanilla") {
@@ -655,7 +813,8 @@ export async function resolveAccessTransformerRuntimeArtifact(svc, input) {
655
813
  gradleUserHome: input.gradleUserHome,
656
814
  requestedScope,
657
815
  atNamespace: input.atNamespace,
658
- loader
816
+ loader,
817
+ projectMinecraftVersion
659
818
  });
660
819
  if (!discovery.selected) {
661
820
  throw createError({
@@ -670,7 +829,13 @@ export async function resolveAccessTransformerRuntimeArtifact(svc, input) {
670
829
  candidateArtifacts: discovery.candidateArtifacts,
671
830
  loaderEvidence: loaderDetection.evidence,
672
831
  loaderWarnings: loaderDetection.warnings,
673
- nextAction: "Provide projectPath for a Forge/NeoForge workspace with generated runtime jars, or run the Gradle tasks that populate transformed runtime artifacts before retrying."
832
+ ...(projectMinecraftVersion ? { projectMinecraftVersion } : {}),
833
+ // An error must never ask for something the caller already sent.
834
+ nextAction: normalizedProjectPath
835
+ ? `Searched the workspace "${normalizedProjectPath}" and the Gradle caches but found no runtime jar for Minecraft ${version}${projectMinecraftVersion && projectMinecraftVersion !== version
836
+ ? ` (the workspace declares ${projectMinecraftVersion}; retry with version="${projectMinecraftVersion}" or preferProjectVersion=true)`
837
+ : ""}. Run the Gradle task that populates transformed runtime artifacts (NeoForge/ModDevGradle writes them under build/moddev/artifacts), then retry.`
838
+ : "Provide projectPath for a Forge/NeoForge workspace with generated runtime jars, or run the Gradle tasks that populate transformed runtime artifacts before retrying."
674
839
  }
675
840
  });
676
841
  }
@@ -690,15 +855,38 @@ export async function resolveAccessTransformerRuntimeArtifact(svc, input) {
690
855
  : "Resolved the closest transformed runtime artifact for validation."
691
856
  }
692
857
  : undefined;
858
+ const resolutionNotes = [
859
+ ...(scopeFallback ? [scopeFallback.reason] : []),
860
+ ...(selected.versionEvidence && selected.versionEvidence !== "exact-token"
861
+ ? [
862
+ selected.versionEvidence === "loader-token"
863
+ ? `Runtime jar matched Minecraft ${version} through its loader version token (ModDevGradle names artifacts after the loader, not Minecraft).`
864
+ : `Runtime jar matched Minecraft ${version} because it lives in the workspace build directory of a project declaring that version.`
865
+ ]
866
+ : [])
867
+ ];
868
+ const servedAt = describeServedRuntimeJar({
869
+ jarPath: selected.jarPath,
870
+ requestedVersion: version,
871
+ // An access transformer is a Forge/NeoForge artifact; the workspace loader
872
+ // refines that when it is known.
873
+ expectedLoader: loader === "forge" ? "forge" : loader === "neoforge" ? "neoforge" : undefined
874
+ });
875
+ resolutionNotes.push(...servedAt.notes);
693
876
  return {
694
- version,
877
+ version: servedAt.version,
695
878
  jarPath: selected.jarPath,
879
+ ...(servedAt.requestedVersion ? { requestedVersion: servedAt.requestedVersion } : {}),
880
+ ...(servedAt.versionApproximated ? { versionApproximated: true } : {}),
881
+ servedLoader: servedAt.servedLoader,
882
+ ...(servedAt.expectedLoader ? { expectedLoader: servedAt.expectedLoader } : {}),
883
+ ...(servedAt.loaderMismatch ? { loaderMismatch: true } : {}),
696
884
  requestedScope,
697
885
  appliedScope: selected.appliedScope,
698
886
  requestedMapping: input.atNamespace,
699
887
  mappingApplied,
700
888
  origin: selected.origin,
701
- resolutionNotes: scopeFallback ? [scopeFallback.reason] : undefined,
889
+ resolutionNotes: resolutionNotes.length > 0 ? resolutionNotes : undefined,
702
890
  scopeFallback
703
891
  };
704
892
  }
@@ -748,7 +936,7 @@ export async function resolveBinaryFallbackArtifact(svc, input) {
748
936
  return undefined;
749
937
  }
750
938
  try {
751
- const fallbackResolved = await resolveSourceTargetInternal({ kind: "jar", value: binaryJarPath }, { allowDecompile: true, preferBinaryOnly: true }, svc.config);
939
+ const fallbackResolved = await resolveSourceTargetInternal({ kind: "jar", value: binaryJarPath }, { allowDecompile: input.allowDecompile ?? true, preferBinaryOnly: true }, svc.config);
752
940
  fallbackResolved.version = fallbackResolved.version ?? input.version;
753
941
  fallbackResolved.coordinate = fallbackResolved.coordinate ?? input.coordinate;
754
942
  fallbackResolved.requestedMapping = input.requestedMapping;
@@ -1233,7 +1421,20 @@ export async function resolveArtifact(svc, input) {
1233
1421
  });
1234
1422
  }
1235
1423
  resolved.qualityFlags.push("version-approximated");
1236
- warnings.push(`Requested version "${value}" but resolved source jar does not contain exact version string: ${versionSourceDiscovery.selectedSourceJarPath}`);
1424
+ // Provenance must name the version that was SERVED. Echoing the request
1425
+ // here made a fallback indistinguishable from an exact hit, and left
1426
+ // downstream version context pointing at a version the jar is not.
1427
+ const servedVersion = inferRuntimeJarMinecraftVersion(versionSourceDiscovery.selectedSourceJarPath);
1428
+ provenance.versionApproximation = {
1429
+ requestedVersion: value,
1430
+ ...(servedVersion ? { servedVersion } : {}),
1431
+ sourceJarPath: versionSourceDiscovery.selectedSourceJarPath
1432
+ };
1433
+ if (servedVersion) {
1434
+ provenance.resolvedFrom.version = servedVersion;
1435
+ }
1436
+ warnings.push(`Requested version "${value}" but resolved source jar does not contain exact version string: ${versionSourceDiscovery.selectedSourceJarPath}` +
1437
+ (servedVersion ? ` (serving Minecraft ${servedVersion})` : ""));
1237
1438
  }
1238
1439
  }
1239
1440
  resolved.qualityFlags = dedupeQualityFlags(resolved.qualityFlags);
@@ -40,7 +40,28 @@ export declare function buildFallbackProvenance(svc: SourceService, input: {
40
40
  export declare function buildClassSourceNotFoundError(svc: SourceService, input: {
41
41
  className: string;
42
42
  lookupClassName: string;
43
+ /**
44
+ * Artifact the lookup ended on. TWO internal paths move it off the requested
45
+ * artifact: the binary fallback, and the nested-jar redirect that follows a
46
+ * shell jar's bundled inner jar.
47
+ */
43
48
  artifactId: string;
49
+ /**
50
+ * Artifact the caller actually asked about. Both internal redirects swap the
51
+ * active artifact mid-lookup, but the error must keep answering about the
52
+ * requested one: reporting the redirect target sends the caller to an artifact
53
+ * they never named. Defaults to `artifactId` for paths with no redirect.
54
+ */
55
+ requestedArtifactId?: string;
56
+ /**
57
+ * Mapping and quality flags OF THE REQUESTED ARTIFACT. `details.artifactId`
58
+ * names the requested artifact, so `details.mapping` (which reaches
59
+ * `error.context` through the allowlist) and `details.qualityFlags` have to
60
+ * describe that same artifact. The nested-jar redirect REPLACES the active
61
+ * values with the inner jar's, which would otherwise publish one artifact's
62
+ * identity beside another's namespace and quality — and point the
63
+ * `suggestedCall` at an index holding neither the class nor its siblings.
64
+ */
44
65
  mappingApplied: SourceMapping;
45
66
  requestedMapping: SourceMapping;
46
67
  qualityFlags: string[];
@@ -10,7 +10,7 @@ import { collectDidYouMeanCandidates } from "./did-you-mean.js";
10
10
  import { matchesMemberPattern } from "./member-pattern.js";
11
11
  import { findNestedJarClasses, resolveUniqueNestedJarForClass } from "./nested-jars.js";
12
12
  import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "../page-cursor.js";
13
- import { dedupeQualityFlags, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
13
+ import { dedupeQualityFlags, inheritArtifactMapping, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
14
14
  import { isUnobfuscatedVersion } from "../version-service.js";
15
15
  const MEMBERS_STATUS_LEGACY = process.env.MEMBERS_STATUS_LEGACY === "1";
16
16
  /**
@@ -175,10 +175,26 @@ export function buildFallbackProvenance(svc, input) {
175
175
  transformChain
176
176
  };
177
177
  }
178
+ /**
179
+ * Near-miss candidates from the requested artifact's index followed by any the
180
+ * lookup's final artifact contributes, deduplicated by FQN so a class present in
181
+ * both is reported once, under the requested artifact.
182
+ */
183
+ function unionDidYouMeanCandidates(svc, requestedArtifactId, endedOnArtifactId, className) {
184
+ const requested = collectDidYouMeanCandidates(svc, requestedArtifactId, className);
185
+ if (endedOnArtifactId === requestedArtifactId) {
186
+ return requested;
187
+ }
188
+ const seen = new Set(requested.map((candidate) => candidate.className));
189
+ const fromFallback = collectDidYouMeanCandidates(svc, endedOnArtifactId, className, endedOnArtifactId).filter((candidate) => !seen.has(candidate.className));
190
+ return [...requested, ...fromFallback];
191
+ }
178
192
  export function buildClassSourceNotFoundError(svc, input) {
179
193
  const simpleName = input.className.split(/[.$]/).at(-1) ?? input.className;
194
+ const requestedArtifactId = input.requestedArtifactId ?? input.artifactId;
180
195
  const details = {
181
- artifactId: input.artifactId,
196
+ artifactId: requestedArtifactId,
197
+ ...(input.artifactId !== requestedArtifactId ? { fallbackArtifactId: input.artifactId } : {}),
182
198
  className: input.className,
183
199
  mapping: input.mappingApplied,
184
200
  qualityFlags: input.qualityFlags,
@@ -191,12 +207,18 @@ export function buildClassSourceNotFoundError(svc, input) {
191
207
  ...(input.nestedJars && input.nestedJars.length > 0 ? { nestedJars: input.nestedJars } : {}),
192
208
  // Candidates are hints from the symbol index, never assertions that the
193
209
  // class exists at the suggested location; empty when nothing usable.
194
- didYouMean: collectDidYouMeanCandidates(svc, input.artifactId, input.className)
210
+ //
211
+ // Both indexes are consulted, requested artifact first. Collecting from the
212
+ // requested artifact ALONE is empty by construction in the scenario the
213
+ // partial-source fallback exists to serve: that artifact is the one without
214
+ // net.minecraft symbols, which is why the fallback fired and indexed the other
215
+ // jar. Candidates from the artifact the caller did not name carry its id.
216
+ didYouMean: unionDidYouMeanCandidates(svc, requestedArtifactId, input.artifactId, input.className)
195
217
  };
196
218
  let nextAction = `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`;
197
219
  let suggestionSpec = {
198
220
  tool: "find-class",
199
- params: { className: simpleName, artifactId: input.artifactId }
221
+ params: { className: simpleName, artifactId: requestedArtifactId }
200
222
  };
201
223
  if (input.targetKind === "version" && input.scope && input.scope !== "merged" && !input.projectPath) {
202
224
  nextAction +=
@@ -225,7 +247,7 @@ export function buildClassSourceNotFoundError(svc, input) {
225
247
  else {
226
248
  suggestionSpec = {
227
249
  tool: "find-class",
228
- params: { className: simpleName, artifactId: input.artifactId }
250
+ params: { className: simpleName, artifactId: requestedArtifactId }
229
251
  };
230
252
  }
231
253
  }
@@ -403,13 +425,14 @@ export function findClass(svc, input) {
403
425
  const filteredMatches = partialVanillaLookup && matches.every((match) => !match.qualifiedName.startsWith("net.minecraft.") && !match.qualifiedName.startsWith("com.mojang."))
404
426
  ? []
405
427
  : matches;
406
- if (filteredMatches.length === 0 && partialVanillaLookup) {
407
- warnings.push(`Artifact source coverage is partial and excludes net.minecraft; returning non-vanilla matches for "${className}" would be misleading. Use get-class-source/get-class-members for binary fallback or get-class-api-matrix for mapped API inspection.`);
408
- }
409
- if (filteredMatches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
410
- warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
411
- }
412
- return { matches: filteredMatches, total: filteredMatches.length, warnings };
428
+ return finishFindClass(svc, {
429
+ artifact,
430
+ artifactId,
431
+ className,
432
+ matches: filteredMatches,
433
+ partialVanillaLookup,
434
+ warnings
435
+ });
413
436
  }
414
437
  const result = svc.symbolsRepo.findScopedSymbols({
415
438
  artifactId,
@@ -418,32 +441,82 @@ export function findClass(svc, input) {
418
441
  symbolKinds: TYPE_SYMBOL_KINDS,
419
442
  limit: limit * 5
420
443
  });
421
- const matches = [];
444
+ // The extractor stores ONE qualifiedName per FILE (the top-level type), so a
445
+ // nested type's row carries its OUTER type's FQN. Reporting that verbatim
446
+ // returned a qualifiedName that did not contain the searched token at all —
447
+ // feeding match[0] into get-class-source then fetched the wrong class.
448
+ const candidates = [];
422
449
  for (const row of result.items) {
423
- if (matches.length >= limit)
424
- break;
425
450
  const isTypeSymbol = row.symbolKind === "class" || row.symbolKind === "interface" ||
426
451
  row.symbolKind === "enum" || row.symbolKind === "record";
427
452
  if (!isTypeSymbol)
428
453
  continue;
429
- matches.push({
430
- qualifiedName: row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", "."),
454
+ const enclosingQualifiedName = row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
455
+ const enclosingSimpleName = enclosingQualifiedName.split(".").at(-1) ?? enclosingQualifiedName;
456
+ const nested = enclosingSimpleName !== row.symbolName;
457
+ candidates.push({
458
+ qualifiedName: nested ? `${enclosingQualifiedName}.${row.symbolName}` : enclosingQualifiedName,
431
459
  filePath: row.filePath,
432
460
  line: row.line,
433
- symbolKind: row.symbolKind
461
+ symbolKind: row.symbolKind,
462
+ ...(nested ? { nested: true, enclosingClass: enclosingQualifiedName } : {})
434
463
  });
435
464
  }
465
+ // A top-level type named exactly like the query is what the caller almost
466
+ // always means; a nested type that merely shares the simple name comes after.
467
+ candidates.sort((left, right) => {
468
+ const leftNested = left.nested === true ? 1 : 0;
469
+ const rightNested = right.nested === true ? 1 : 0;
470
+ if (leftNested !== rightNested)
471
+ return leftNested - rightNested;
472
+ return left.qualifiedName.localeCompare(right.qualifiedName);
473
+ });
474
+ const matches = candidates.slice(0, limit);
436
475
  const partialVanillaLookup = hasPartialNetMinecraftCoverage(artifact.qualityFlags) && looksLikeDeobfuscatedClassName(className);
437
476
  const filteredMatches = partialVanillaLookup && matches.every((match) => !match.qualifiedName.startsWith("net.minecraft.") && !match.qualifiedName.startsWith("com.mojang."))
438
477
  ? []
439
478
  : matches;
440
- if (filteredMatches.length === 0 && partialVanillaLookup) {
479
+ return finishFindClass(svc, {
480
+ artifact,
481
+ artifactId,
482
+ className,
483
+ matches: filteredMatches,
484
+ partialVanillaLookup,
485
+ warnings
486
+ });
487
+ }
488
+ /**
489
+ * Shared tail for both findClass branches: coverage/namespace warnings plus a
490
+ * machine-usable recovery route.
491
+ *
492
+ * An empty result on an artifact whose index excludes net.minecraft is
493
+ * internally consistent but externally contradictory — get-class-source answers
494
+ * the SAME class from the binary fallback. The caller therefore gets both the
495
+ * reason and the exact call that works.
496
+ */
497
+ function finishFindClass(svc, input) {
498
+ const { artifact, artifactId, className, matches, partialVanillaLookup, warnings } = input;
499
+ let suggestedCall;
500
+ if (matches.length === 0 && partialVanillaLookup) {
441
501
  warnings.push(`Artifact source coverage is partial and excludes net.minecraft; returning non-vanilla matches for "${className}" would be misleading. Use get-class-source/get-class-members for binary fallback or get-class-api-matrix for mapped API inspection.`);
502
+ suggestedCall = buildSuggestedCall({
503
+ tool: "get-class-source",
504
+ params: {
505
+ className,
506
+ target: { kind: "artifact", artifactId },
507
+ mode: "metadata"
508
+ }
509
+ }).suggestedCall;
442
510
  }
443
- if (filteredMatches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
511
+ if (matches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
444
512
  warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
445
513
  }
446
- return { matches: filteredMatches, total: filteredMatches.length, warnings };
514
+ return {
515
+ matches,
516
+ total: matches.length,
517
+ warnings,
518
+ ...(suggestedCall ? { suggestedCall } : {})
519
+ };
447
520
  }
448
521
  export async function findClassIncludingNested(svc, input) {
449
522
  const indexed = findClass(svc, input);
@@ -550,7 +623,7 @@ export async function getClassSource(svc, input) {
550
623
  const artifact = svc.getArtifact(artifactId);
551
624
  artifactId = artifact.artifactId;
552
625
  origin = artifact.origin;
553
- requestedMapping = input.mapping != null ? requestedMapping : (artifact.requestedMapping ?? requestedMapping);
626
+ requestedMapping = inheritArtifactMapping(input.mapping, artifact);
554
627
  mappingApplied = artifact.mappingApplied ?? requestedMapping;
555
628
  provenance = artifact.provenance;
556
629
  qualityFlags = artifact.qualityFlags;
@@ -595,7 +668,11 @@ export async function getClassSource(svc, input) {
595
668
  requestedMapping,
596
669
  mappingApplied,
597
670
  provenance: activeProvenance,
598
- qualityFlags: activeQualityFlags
671
+ qualityFlags: activeQualityFlags,
672
+ // A caller who declined decompilation must not pay for one here: the
673
+ // fallback used to hardcode allowDecompile:true and silently ran a full
674
+ // Vineflower pass on the binary jar.
675
+ allowDecompile: input.allowDecompile
599
676
  });
600
677
  if (!fallbackResolved || fallbackResolved.artifactId === activeArtifactId) {
601
678
  return false;
@@ -689,11 +766,15 @@ export async function getClassSource(svc, input) {
689
766
  if (!filePath) {
690
767
  throw buildClassSourceNotFoundError(svc, {
691
768
  artifactId: activeArtifactId,
769
+ requestedArtifactId: artifactId,
692
770
  className,
693
771
  lookupClassName: activeLookupClassName,
694
- mappingApplied: activeMappingApplied,
772
+ // The REQUESTED artifact's namespace and quality, matching the artifactId this
773
+ // error reports. The nested-jar redirect replaces the active values with the
774
+ // inner jar's, which would otherwise describe an artifact the caller never named.
775
+ mappingApplied,
695
776
  requestedMapping,
696
- qualityFlags: activeQualityFlags,
777
+ qualityFlags,
697
778
  attemptedBinaryFallback,
698
779
  targetKind: input.target?.kind,
699
780
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
@@ -728,11 +809,15 @@ export async function getClassSource(svc, input) {
728
809
  if (!row) {
729
810
  throw buildClassSourceNotFoundError(svc, {
730
811
  artifactId: activeArtifactId,
812
+ requestedArtifactId: artifactId,
731
813
  className,
732
814
  lookupClassName: activeLookupClassName,
733
- mappingApplied: activeMappingApplied,
815
+ // The REQUESTED artifact's namespace and quality, matching the artifactId this
816
+ // error reports. The nested-jar redirect replaces the active values with the
817
+ // inner jar's, which would otherwise describe an artifact the caller never named.
818
+ mappingApplied,
734
819
  requestedMapping,
735
- qualityFlags: activeQualityFlags,
820
+ qualityFlags,
736
821
  attemptedBinaryFallback,
737
822
  filePath,
738
823
  targetKind: input.target?.kind,
@@ -910,6 +995,7 @@ export async function getClassMembers(svc, input) {
910
995
  const artifact = svc.getArtifact(artifactId);
911
996
  artifactId = artifact.artifactId;
912
997
  origin = artifact.origin;
998
+ requestedMapping = inheritArtifactMapping(input.mapping, artifact);
913
999
  mappingApplied = artifact.mappingApplied ?? requestedMapping;
914
1000
  provenance = artifact.provenance;
915
1001
  qualityFlags = artifact.qualityFlags;
@@ -1179,7 +1265,18 @@ export async function getClassMembers(svc, input) {
1179
1265
  truncated,
1180
1266
  ...(nextCursor ? { nextCursor } : {}),
1181
1267
  ...(memberCursorIgnored ? { cursorIgnored: true } : {}),
1182
- context: signatureContext,
1268
+ context: {
1269
+ ...signatureContext,
1270
+ // The jar-derived context describes the BYTECODE the reader opened. The
1271
+ // members handed back have already been remapped into requestedMapping,
1272
+ // so echoing the jar namespace here contradicted `returnedNamespace` in
1273
+ // the same payload. Report what the response actually contains, and fill
1274
+ // the version the resolver established when the jar path yielded none.
1275
+ ...(signatureContext.minecraftVersion === "unknown" && version
1276
+ ? { minecraftVersion: version }
1277
+ : {}),
1278
+ mappingNamespace: requestedMapping
1279
+ },
1183
1280
  origin,
1184
1281
  artifactId,
1185
1282
  requestedMapping,