@adhisang/minecraft-modding-mcp 1.1.1 → 1.2.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.
@@ -1,4 +1,9 @@
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";
6
+ import fastGlob from "fast-glob";
2
7
  import { createError, ERROR_CODES, isAppError } from "./errors.js";
3
8
  import { loadConfig } from "./config.js";
4
9
  import { decompileBinaryJar } from "./decompiler/vineflower.js";
@@ -12,7 +17,7 @@ import { resolveSourceTarget as resolveSourceTargetInternal } from "./source-res
12
17
  import { applyMappingPipeline } from "./mapping-pipeline-service.js";
13
18
  import { MappingService } from "./mapping-service.js";
14
19
  import { extractSymbolsFromSource } from "./symbols/symbol-extractor.js";
15
- import { iterateJavaEntriesAsUtf8 } from "./source-jar-reader.js";
20
+ import { iterateJavaEntriesAsUtf8, listJavaEntries } from "./source-jar-reader.js";
16
21
  import { openDatabase } from "./storage/db.js";
17
22
  import { ArtifactsRepo } from "./storage/artifacts-repo.js";
18
23
  import { FilesRepo } from "./storage/files-repo.js";
@@ -20,6 +25,7 @@ import { IndexMetaRepo } from "./storage/index-meta-repo.js";
20
25
  import { SymbolsRepo } from "./storage/symbols-repo.js";
21
26
  import { RuntimeMetrics } from "./observability.js";
22
27
  import { log } from "./logger.js";
28
+ import { normalizePathForHost } from "./path-converter.js";
23
29
  import { createSearchHitAccumulator, decodeSearchCursor, encodeSearchCursor } from "./search-hit-accumulator.js";
24
30
  import { WorkspaceMappingService } from "./workspace-mapping-service.js";
25
31
  import { VersionService, isUnobfuscatedVersion } from "./version-service.js";
@@ -27,6 +33,24 @@ import { RegistryService } from "./registry-service.js";
27
33
  import { VersionDiffService } from "./version-diff-service.js";
28
34
  import { ModDecompileService } from "./mod-decompile-service.js";
29
35
  import { ModSearchService } from "./mod-search-service.js";
36
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
37
+ function truncateUtf8ToMaxBytes(content, maxBytes) {
38
+ const encoded = Buffer.from(content, "utf8");
39
+ if (encoded.length <= maxBytes) {
40
+ return content;
41
+ }
42
+ let end = Math.max(0, Math.min(maxBytes, encoded.length));
43
+ while (end > 0) {
44
+ try {
45
+ const decoded = utf8Decoder.decode(encoded.subarray(0, end));
46
+ return decoded;
47
+ }
48
+ catch {
49
+ end -= 1;
50
+ }
51
+ }
52
+ return "";
53
+ }
30
54
  const INDEX_SCHEMA_VERSION = 1;
31
55
  const SYMBOL_KINDS = ["class", "interface", "enum", "record", "method", "field"];
32
56
  function isSymbolKind(value) {
@@ -43,6 +67,43 @@ const MAX_REGEX_RESULT_LIMIT = 100;
43
67
  function normalizePathStyle(path) {
44
68
  return path.replaceAll("\\", "/");
45
69
  }
70
+ function escapeRegexLiteral(value) {
71
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
+ }
73
+ function hasExactVersionToken(path, version) {
74
+ const normalizedPath = normalizePathStyle(path).toLowerCase();
75
+ const normalizedVersion = version.trim().toLowerCase();
76
+ if (!normalizedVersion) {
77
+ return false;
78
+ }
79
+ // Avoid prefix false-positives like "1.21.1" matching "1.21.10".
80
+ const pattern = new RegExp(`(^|[^0-9a-z])${escapeRegexLiteral(normalizedVersion)}([^0-9a-z]|$)`, "i");
81
+ return pattern.test(normalizedPath);
82
+ }
83
+ function normalizeOptionalProjectPath(projectPath) {
84
+ if (!projectPath) {
85
+ return undefined;
86
+ }
87
+ const trimmed = projectPath.trim();
88
+ if (!trimmed) {
89
+ return undefined;
90
+ }
91
+ const normalized = normalizePathForHost(trimmed, undefined, "projectPath");
92
+ return isAbsolute(normalized) ? normalized : resolvePath(process.cwd(), normalized);
93
+ }
94
+ function buildVersionSourceSearchRoots(projectPath) {
95
+ const roots = new Set();
96
+ if (projectPath) {
97
+ roots.add(resolvePath(projectPath, ".gradle", "loom-cache"));
98
+ roots.add(resolvePath(projectPath, ".gradle-user", "caches", "fabric-loom"));
99
+ roots.add(resolvePath(projectPath, ".gradle", "caches", "fabric-loom"));
100
+ return [...roots];
101
+ }
102
+ const homeGradle = resolvePath(homedir(), ".gradle");
103
+ roots.add(resolvePath(homeGradle, "loom-cache"));
104
+ roots.add(resolvePath(homeGradle, "caches", "fabric-loom"));
105
+ return [...roots];
106
+ }
46
107
  function parseQualifiedMethodSymbol(symbol) {
47
108
  const trimmed = symbol.trim();
48
109
  const separator = trimmed.lastIndexOf(".");
@@ -91,6 +152,20 @@ function normalizeStrictPositiveInt(value, field) {
91
152
  }
92
153
  return value;
93
154
  }
155
+ const COMMON_SOURCE_ROOTS = [
156
+ "src/main/java",
157
+ "src/client/java",
158
+ "common/src/main/java",
159
+ "common/src/client/java",
160
+ "fabric/src/main/java",
161
+ "fabric/src/client/java",
162
+ "neoforge/src/main/java",
163
+ "neoforge/src/client/java",
164
+ "forge/src/main/java",
165
+ "forge/src/client/java",
166
+ "quilt/src/main/java",
167
+ "quilt/src/client/java"
168
+ ];
94
169
  function normalizeMapping(mapping) {
95
170
  if (mapping == null) {
96
171
  return "official";
@@ -104,7 +179,11 @@ function normalizeMapping(mapping) {
104
179
  throw createError({
105
180
  code: ERROR_CODES.MAPPING_UNAVAILABLE,
106
181
  message: `Unsupported mapping "${mapping}".`,
107
- details: { mapping }
182
+ details: {
183
+ mapping,
184
+ nextAction: "Try mapping=official which is always available.",
185
+ suggestedCall: { tool: "resolve-artifact", params: { mapping: "official" } }
186
+ }
108
187
  });
109
188
  }
110
189
  function normalizeAccessWidenerNamespace(namespace) {
@@ -347,6 +426,7 @@ function buildSearchCursorContext(input) {
347
426
  query: input.query,
348
427
  intent: input.intent,
349
428
  match: input.match,
429
+ queryMode: input.queryMode,
350
430
  includeDefinition: input.includeDefinition,
351
431
  packagePrefix: input.scope?.packagePrefix ?? "",
352
432
  fileGlob: input.scope?.fileGlob ?? "",
@@ -522,11 +602,95 @@ export class SourceService {
522
602
  this.symbolsRepo = new SymbolsRepo(this.db);
523
603
  this.refreshCacheMetrics();
524
604
  }
605
+ async discoverVersionSourceJar(input) {
606
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
607
+ const searchRoots = buildVersionSourceSearchRoots(normalizedProjectPath);
608
+ const searchedPaths = [];
609
+ const candidates = [];
610
+ const seen = new Set();
611
+ for (const root of searchRoots) {
612
+ searchedPaths.push(root);
613
+ let discovered = [];
614
+ try {
615
+ discovered = fastGlob.sync("**/*sources.jar", {
616
+ cwd: root,
617
+ absolute: true,
618
+ onlyFiles: true
619
+ });
620
+ }
621
+ catch {
622
+ continue;
623
+ }
624
+ for (const candidatePath of discovered) {
625
+ const normalizedPath = normalizePathStyle(candidatePath);
626
+ if (seen.has(normalizedPath)) {
627
+ continue;
628
+ }
629
+ seen.add(normalizedPath);
630
+ const lower = normalizedPath.toLowerCase();
631
+ if (!lower.includes(input.version.toLowerCase()) && !lower.includes("minecraft")) {
632
+ continue;
633
+ }
634
+ let javaEntries = [];
635
+ try {
636
+ javaEntries = await listJavaEntries(normalizedPath);
637
+ }
638
+ catch {
639
+ continue;
640
+ }
641
+ if (javaEntries.length === 0) {
642
+ continue;
643
+ }
644
+ const hasMinecraftNamespace = javaEntries.some((entry) => normalizePathStyle(entry).startsWith("net/minecraft/"));
645
+ const score = (hasMinecraftNamespace ? 10_000 : 0) +
646
+ (lower.includes("minecraft-merged") ? 2_000 : 0) +
647
+ (lower.includes(input.version.toLowerCase()) ? 1_000 : 0) +
648
+ Math.min(javaEntries.length, 500);
649
+ candidates.push({
650
+ jarPath: normalizedPath,
651
+ javaEntryCount: javaEntries.length,
652
+ hasMinecraftNamespace,
653
+ score
654
+ });
655
+ }
656
+ }
657
+ candidates.sort((left, right) => {
658
+ if (right.score !== left.score) {
659
+ return right.score - left.score;
660
+ }
661
+ return left.jarPath.localeCompare(right.jarPath);
662
+ });
663
+ const selected = candidates.find((candidate) => candidate.hasMinecraftNamespace) ?? candidates[0];
664
+ const candidateArtifacts = candidates
665
+ .slice(0, 20)
666
+ .map((candidate) => `${candidate.jarPath}#java=${candidate.javaEntryCount}`);
667
+ return {
668
+ searchedPaths,
669
+ candidateArtifacts,
670
+ selectedSourceJarPath: selected?.jarPath
671
+ };
672
+ }
673
+ buildVersionSourceRecoveryCommand(projectPath) {
674
+ const normalizedProjectPath = normalizeOptionalProjectPath(projectPath);
675
+ const prefix = normalizedProjectPath
676
+ ? `cd ${JSON.stringify(normalizedProjectPath)} && `
677
+ : "";
678
+ return `${prefix}./gradlew genSources --no-daemon`;
679
+ }
525
680
  async resolveArtifact(input) {
526
681
  const kind = input.target.kind;
527
- const value = input.target.value?.trim();
682
+ let value = input.target.value?.trim();
528
683
  const mapping = normalizeMapping(input.mapping);
684
+ const scope = input.scope;
529
685
  const warnings = [];
686
+ // P5: preferProjectVersion - detect MC version from gradle.properties
687
+ if (input.preferProjectVersion && input.projectPath && kind === "version") {
688
+ const detected = await this.workspaceMappingService.detectProjectMinecraftVersion(input.projectPath);
689
+ if (detected && detected !== value) {
690
+ warnings.push(`Overriding version "${value}" with project version "${detected}" from gradle.properties.`);
691
+ }
692
+ value = detected ?? value;
693
+ }
530
694
  if (!value) {
531
695
  throw createError({
532
696
  code: ERROR_CODES.INVALID_INPUT,
@@ -552,6 +716,7 @@ export class SourceService {
552
716
  try {
553
717
  let resolvedTarget = { kind, value };
554
718
  let resolvedVersion;
719
+ let versionSourceDiscovery;
555
720
  if (kind === "version") {
556
721
  const versionJar = await this.versionService.resolveVersionJar(value);
557
722
  resolvedVersion = versionJar.version;
@@ -577,6 +742,19 @@ export class SourceService {
577
742
  warnings.push(`Version ${resolvedVersion} is unobfuscated; ${mapping} mappings are not applicable. Using official names.`);
578
743
  effectiveMapping = "official";
579
744
  }
745
+ if (kind === "version" && resolvedVersion && effectiveMapping === "mojang" && scope !== "vanilla") {
746
+ versionSourceDiscovery = await this.discoverVersionSourceJar({
747
+ version: resolvedVersion,
748
+ projectPath: input.projectPath
749
+ });
750
+ if (versionSourceDiscovery.selectedSourceJarPath) {
751
+ resolvedTarget = {
752
+ kind: "jar",
753
+ value: versionSourceDiscovery.selectedSourceJarPath
754
+ };
755
+ warnings.push(`Resolved source-backed artifact from Loom cache candidate: ${versionSourceDiscovery.selectedSourceJarPath}.`);
756
+ }
757
+ }
580
758
  const resolved = await resolveSourceTargetInternal(resolvedTarget, {
581
759
  // mojang requires source-backed artifact guarantee; force resolution to consider decompile candidate
582
760
  // and reject later if mapping cannot be applied.
@@ -594,11 +772,59 @@ export class SourceService {
594
772
  }
595
773
  }, this.config);
596
774
  resolved.version = resolvedVersion;
597
- const mappingDecision = applyMappingPipeline({
598
- requestedMapping: effectiveMapping,
599
- target: { kind, value },
600
- resolved
601
- });
775
+ let mappingDecision;
776
+ try {
777
+ mappingDecision = applyMappingPipeline({
778
+ requestedMapping: effectiveMapping,
779
+ target: { kind, value },
780
+ resolved
781
+ });
782
+ }
783
+ catch (caughtError) {
784
+ if (isAppError(caughtError) && caughtError.code === ERROR_CODES.MAPPING_NOT_APPLIED) {
785
+ const isVanillaMojang = scope === "vanilla" && effectiveMapping === "mojang";
786
+ let suggestedCall;
787
+ let nextAction;
788
+ if (isVanillaMojang && input.projectPath) {
789
+ suggestedCall = {
790
+ tool: "resolve-artifact",
791
+ params: { targetKind: kind, targetValue: value, mapping: "mojang", scope: "merged", projectPath: input.projectPath }
792
+ };
793
+ nextAction =
794
+ "scope=vanilla blocks Loom cache discovery needed for mojang mapping. " +
795
+ "Retry with scope=merged to allow source-jar resolution from the project cache.";
796
+ }
797
+ else if (isVanillaMojang) {
798
+ suggestedCall = {
799
+ tool: "resolve-artifact",
800
+ params: { targetKind: kind, targetValue: value, mapping: "official", scope: "vanilla" }
801
+ };
802
+ nextAction =
803
+ "scope=vanilla blocks Loom cache discovery needed for mojang mapping. " +
804
+ "Without a projectPath, use mapping=official to read vanilla obfuscated names.";
805
+ }
806
+ else {
807
+ suggestedCall = {
808
+ tool: "resolve-artifact",
809
+ params: { targetKind: kind, targetValue: value, mapping: "official", ...(scope ? { scope } : {}) }
810
+ };
811
+ nextAction = "Retry with mapping=official to use obfuscated names.";
812
+ }
813
+ throw createError({
814
+ code: ERROR_CODES.MAPPING_NOT_APPLIED,
815
+ message: caughtError.message,
816
+ details: {
817
+ ...(caughtError.details ?? {}),
818
+ searchedPaths: versionSourceDiscovery?.searchedPaths ?? [],
819
+ candidateArtifacts: versionSourceDiscovery?.candidateArtifacts ?? resolved.adjacentSourceCandidates ?? [],
820
+ recommendedCommand: this.buildVersionSourceRecoveryCommand(input.projectPath),
821
+ nextAction,
822
+ suggestedCall
823
+ }
824
+ });
825
+ }
826
+ throw caughtError;
827
+ }
602
828
  const additionalTransformChain = [];
603
829
  if (effectiveMapping === "intermediary" || effectiveMapping === "yarn") {
604
830
  if (!resolved.version) {
@@ -608,7 +834,8 @@ export class SourceService {
608
834
  details: {
609
835
  mapping: effectiveMapping,
610
836
  target: { kind, value },
611
- nextAction: "Use targetKind=version or a versioned Maven coordinate so mapping artifacts can be resolved."
837
+ nextAction: "Use targetKind=version or a versioned Maven coordinate so mapping artifacts can be resolved.",
838
+ suggestedCall: { tool: "resolve-artifact", params: { targetKind: "version", targetValue: value, ...(scope ? { scope } : {}) } }
612
839
  }
613
840
  });
614
841
  }
@@ -631,8 +858,46 @@ export class SourceService {
631
858
  resolved.requestedMapping = effectiveMapping;
632
859
  resolved.mappingApplied = mappingDecision.mappingApplied;
633
860
  resolved.provenance = provenance;
634
- resolved.qualityFlags = mappingDecision.qualityFlags;
861
+ resolved.qualityFlags = [...mappingDecision.qualityFlags];
862
+ if (versionSourceDiscovery?.candidateArtifacts.length) {
863
+ resolved.qualityFlags.push("source-jar-found");
864
+ }
865
+ if (versionSourceDiscovery?.selectedSourceJarPath) {
866
+ resolved.qualityFlags.push("source-jar-validated");
867
+ if (kind === "version" && !hasExactVersionToken(versionSourceDiscovery.selectedSourceJarPath, value)) {
868
+ if (input.strictVersion) {
869
+ throw createError({
870
+ code: ERROR_CODES.VERSION_NOT_FOUND,
871
+ message: `Strict version match failed: requested "${value}" but nearest source jar is for a different version.`,
872
+ details: {
873
+ requestedVersion: value,
874
+ selectedSourceJar: versionSourceDiscovery.selectedSourceJarPath,
875
+ candidateArtifacts: versionSourceDiscovery.candidateArtifacts,
876
+ nextAction: "Use strictVersion=false (default) to allow approximation, or ensure the exact version source jar is in the Loom cache.",
877
+ suggestedCall: { tool: "resolve-artifact", params: { targetKind: "version", targetValue: value, strictVersion: false } }
878
+ }
879
+ });
880
+ }
881
+ resolved.qualityFlags.push("version-approximated");
882
+ warnings.push(`Requested version "${value}" but resolved source jar does not contain exact version string: ${versionSourceDiscovery.selectedSourceJarPath}`);
883
+ }
884
+ }
885
+ resolved.qualityFlags = [...new Set(resolved.qualityFlags)];
635
886
  await this.ingestIfNeeded(resolved);
887
+ let sampleEntries;
888
+ if (resolved.sourceJarPath) {
889
+ try {
890
+ const javaEntries = await listJavaEntries(resolved.sourceJarPath);
891
+ const MAX_SAMPLE = 10;
892
+ sampleEntries = javaEntries.slice(0, MAX_SAMPLE);
893
+ if (javaEntries.length > MAX_SAMPLE) {
894
+ sampleEntries.push(`... and ${javaEntries.length - MAX_SAMPLE} more .java entries`);
895
+ }
896
+ }
897
+ catch {
898
+ // non-fatal: sampleEntries remains undefined
899
+ }
900
+ }
636
901
  return {
637
902
  artifactId: resolved.artifactId,
638
903
  origin: resolved.origin,
@@ -645,9 +910,10 @@ export class SourceService {
645
910
  requestedMapping: effectiveMapping,
646
911
  mappingApplied: mappingDecision.mappingApplied,
647
912
  provenance,
648
- qualityFlags: mappingDecision.qualityFlags,
913
+ qualityFlags: resolved.qualityFlags,
649
914
  repoUrl: resolved.repoUrl,
650
- warnings
915
+ warnings,
916
+ sampleEntries
651
917
  };
652
918
  }
653
919
  catch (caughtError) {
@@ -701,11 +967,13 @@ export class SourceService {
701
967
  const snippetWindow = buildSnippetWindow(input.include?.snippetLines);
702
968
  const regexPattern = match === "regex" ? compileRegex(query) : undefined;
703
969
  const scope = input.scope;
970
+ const queryMode = input.queryMode ?? "auto";
704
971
  const cursorContext = buildSearchCursorContext({
705
972
  artifactId: artifact.artifactId,
706
973
  query,
707
974
  intent,
708
975
  match,
976
+ queryMode,
709
977
  scope,
710
978
  includeDefinition
711
979
  });
@@ -720,6 +988,8 @@ export class SourceService {
720
988
  const recordHit = (hit) => {
721
989
  accumulator.add(hit);
722
990
  };
991
+ const hasSeparators = /[._$]/.test(query);
992
+ const tokenOnlyTextIntent = intent === "text" && queryMode === "token";
723
993
  if (intent === "symbol") {
724
994
  this.searchSymbolIntent(artifact.artifactId, query, match, scope, snippetWindow, regexPattern, recordHit);
725
995
  // WS4: Use repo-level COUNT for symbol totalApprox when not regex
@@ -734,14 +1004,21 @@ export class SourceService {
734
1004
  accumulator.setTotalApproxOverride(approxCount);
735
1005
  }
736
1006
  }
1007
+ else if (queryMode === "literal" && intent === "text") {
1008
+ // F-03: queryMode=literal forces substring scan for text intent
1009
+ this.metrics.recordSearchFallback();
1010
+ this.searchTextIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
1011
+ }
737
1012
  else if (!indexedSearchEnabled) {
738
1013
  this.metrics.recordIndexedDisabled();
739
- this.metrics.recordSearchFallback();
740
- if (intent === "path") {
741
- this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
742
- }
743
- else {
744
- this.searchTextIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
1014
+ if (!tokenOnlyTextIntent) {
1015
+ this.metrics.recordSearchFallback();
1016
+ if (intent === "path") {
1017
+ this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
1018
+ }
1019
+ else {
1020
+ this.searchTextIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
1021
+ }
745
1022
  }
746
1023
  }
747
1024
  else if (canUseIndexedSearchPath(indexedSearchEnabled, intent, match, scope)) {
@@ -757,6 +1034,10 @@ export class SourceService {
757
1034
  // WS4: Use repo-level COUNT for totalApprox instead of accumulator count
758
1035
  const approxCount = this.filesRepo.countTextCandidates(artifact.artifactId, query);
759
1036
  accumulator.setTotalApproxOverride(approxCount);
1037
+ // F-03: queryMode=auto fallback — when indexed returns 0 hits and query has separators, retry with literal scan
1038
+ if (queryMode === "auto" && hasSeparators && accumulator.currentCount() === 0) {
1039
+ this.searchTextIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
1040
+ }
760
1041
  }
761
1042
  this.metrics.recordSearchIndexedHit();
762
1043
  }
@@ -768,6 +1049,20 @@ export class SourceService {
768
1049
  match,
769
1050
  reason: caughtError instanceof Error ? caughtError.message : String(caughtError)
770
1051
  });
1052
+ // F-03: queryMode=token suppresses error-path fallback to brute-force scan
1053
+ if (!tokenOnlyTextIntent) {
1054
+ if (intent === "path") {
1055
+ this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
1056
+ }
1057
+ else {
1058
+ this.searchTextIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
1059
+ }
1060
+ }
1061
+ }
1062
+ }
1063
+ else {
1064
+ if (!tokenOnlyTextIntent) {
1065
+ this.metrics.recordSearchFallback();
771
1066
  if (intent === "path") {
772
1067
  this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
773
1068
  }
@@ -776,15 +1071,6 @@ export class SourceService {
776
1071
  }
777
1072
  }
778
1073
  }
779
- else {
780
- this.metrics.recordSearchFallback();
781
- if (intent === "path") {
782
- this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
783
- }
784
- else {
785
- this.searchTextIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
786
- }
787
- }
788
1074
  this.metrics.recordSearchIntentDuration(intent, Date.now() - intentStartedAt);
789
1075
  const finalizedHits = accumulator.finalize();
790
1076
  const page = finalizedHits.page;
@@ -808,11 +1094,14 @@ export class SourceService {
808
1094
  this.metrics.recordOneHopExpansion(relations.length);
809
1095
  }
810
1096
  this.metrics.recordSearchTokenBytesReturned(Buffer.byteLength(JSON.stringify({ hits: page, relations }), "utf8"));
1097
+ // B5: If post-filtering eliminated all hits on the first page, the SQL-based
1098
+ // totalApprox is misleading — correct it to 0.
1099
+ const totalApprox = page.length === 0 && !cursor ? 0 : finalizedHits.totalApprox;
811
1100
  return {
812
1101
  hits: page,
813
1102
  relations: relations && relations.length > 0 ? relations : undefined,
814
1103
  nextCursor,
815
- totalApprox: finalizedHits.totalApprox,
1104
+ totalApprox,
816
1105
  mappingApplied: artifact.mappingApplied ?? "official"
817
1106
  };
818
1107
  }
@@ -835,9 +1124,7 @@ export class SourceService {
835
1124
  const maxBytes = clampLimit(input.maxBytes, this.config.maxContentBytes, Number.MAX_SAFE_INTEGER);
836
1125
  const fullBytes = Buffer.byteLength(row.content, "utf8");
837
1126
  const truncated = fullBytes > maxBytes;
838
- const content = truncated
839
- ? Buffer.from(row.content, "utf8").slice(0, maxBytes).toString("utf8")
840
- : row.content;
1127
+ const content = truncated ? truncateUtf8ToMaxBytes(row.content, maxBytes) : row.content;
841
1128
  if (truncated) {
842
1129
  log("warn", "source.get_file.truncated", {
843
1130
  artifactId: input.artifactId,
@@ -1133,7 +1420,11 @@ export class SourceService {
1133
1420
  throw createError({
1134
1421
  code: ERROR_CODES.VERSION_NOT_FOUND,
1135
1422
  message: "No Minecraft versions were returned by manifest.",
1136
- details: { includeSnapshots }
1423
+ details: {
1424
+ includeSnapshots,
1425
+ nextAction: "Use list-versions to see available Minecraft versions.",
1426
+ suggestedCall: { tool: "list-versions", params: {} }
1427
+ }
1137
1428
  });
1138
1429
  }
1139
1430
  const chronological = [...manifestOrder].reverse();
@@ -1145,14 +1436,22 @@ export class SourceService {
1145
1436
  throw createError({
1146
1437
  code: ERROR_CODES.VERSION_NOT_FOUND,
1147
1438
  message: `fromVersion "${requestedFrom}" was not found in manifest.`,
1148
- details: { fromVersion: requestedFrom }
1439
+ details: {
1440
+ fromVersion: requestedFrom,
1441
+ nextAction: "Use list-versions to see available Minecraft versions.",
1442
+ suggestedCall: { tool: "list-versions", params: {} }
1443
+ }
1149
1444
  });
1150
1445
  }
1151
1446
  if (toIndex < 0) {
1152
1447
  throw createError({
1153
1448
  code: ERROR_CODES.VERSION_NOT_FOUND,
1154
1449
  message: `toVersion "${requestedTo}" was not found in manifest.`,
1155
- details: { toVersion: requestedTo }
1450
+ details: {
1451
+ toVersion: requestedTo,
1452
+ nextAction: "Use list-versions to see available Minecraft versions.",
1453
+ suggestedCall: { tool: "list-versions", params: {} }
1454
+ }
1156
1455
  });
1157
1456
  }
1158
1457
  if (fromIndex > toIndex) {
@@ -1291,7 +1590,11 @@ export class SourceService {
1291
1590
  if (manifestOrder.length === 0) {
1292
1591
  throw createError({
1293
1592
  code: ERROR_CODES.VERSION_NOT_FOUND,
1294
- message: "No Minecraft versions were returned by manifest."
1593
+ message: "No Minecraft versions were returned by manifest.",
1594
+ details: {
1595
+ nextAction: "Use list-versions to see available Minecraft versions.",
1596
+ suggestedCall: { tool: "list-versions", params: {} }
1597
+ }
1295
1598
  });
1296
1599
  }
1297
1600
  const chronological = [...manifestOrder].reverse();
@@ -1301,14 +1604,22 @@ export class SourceService {
1301
1604
  throw createError({
1302
1605
  code: ERROR_CODES.VERSION_NOT_FOUND,
1303
1606
  message: `fromVersion "${fromVersion}" was not found in manifest.`,
1304
- details: { fromVersion }
1607
+ details: {
1608
+ fromVersion,
1609
+ nextAction: "Use list-versions to see available Minecraft versions.",
1610
+ suggestedCall: { tool: "list-versions", params: {} }
1611
+ }
1305
1612
  });
1306
1613
  }
1307
1614
  if (toIndex < 0) {
1308
1615
  throw createError({
1309
1616
  code: ERROR_CODES.VERSION_NOT_FOUND,
1310
1617
  message: `toVersion "${toVersion}" was not found in manifest.`,
1311
- details: { toVersion }
1618
+ details: {
1619
+ toVersion,
1620
+ nextAction: "Use list-versions to see available Minecraft versions.",
1621
+ suggestedCall: { tool: "list-versions", params: {} }
1622
+ }
1312
1623
  });
1313
1624
  }
1314
1625
  if (fromIndex > toIndex) {
@@ -1431,18 +1742,18 @@ export class SourceService {
1431
1742
  : diffMembersByKey(fromMembers.fields, toMembers.fields, (member) => member.name, true);
1432
1743
  // Remap diff delta members for non-official mappings
1433
1744
  const remapDelta = async (delta, kind) => {
1434
- const [remappedAdded, remappedRemoved] = await Promise.all([
1745
+ const [addedResult, removedResult] = await Promise.all([
1435
1746
  this.remapSignatureMembers(delta.added, kind, toVersion, mapping, input.sourcePriority, warnings),
1436
1747
  this.remapSignatureMembers(delta.removed, kind, fromVersion, mapping, input.sourcePriority, warnings)
1437
1748
  ]);
1438
1749
  const remappedModified = await Promise.all(delta.modified.map(async (change) => {
1439
- const [fromArr, toArr] = await Promise.all([
1750
+ const [fromResult, toResult] = await Promise.all([
1440
1751
  this.remapSignatureMembers([change.from], kind, fromVersion, mapping, input.sourcePriority, warnings),
1441
1752
  this.remapSignatureMembers([change.to], kind, toVersion, mapping, input.sourcePriority, warnings)
1442
1753
  ]);
1443
- return { ...change, from: fromArr[0], to: toArr[0] };
1754
+ return { ...change, from: fromResult.members[0], to: toResult.members[0] };
1444
1755
  }));
1445
- return { added: remappedAdded, removed: remappedRemoved, modified: remappedModified };
1756
+ return { added: addedResult.members, removed: removedResult.members, modified: remappedModified };
1446
1757
  };
1447
1758
  const [remappedConstructors, remappedMethods, remappedFields] = await Promise.all([
1448
1759
  remapDelta(constructors, "method"),
@@ -1490,6 +1801,78 @@ export class SourceService {
1490
1801
  warnings
1491
1802
  };
1492
1803
  }
1804
+ findClass(input) {
1805
+ const className = input.className.trim();
1806
+ if (!className) {
1807
+ throw createError({
1808
+ code: ERROR_CODES.INVALID_INPUT,
1809
+ message: "className must be non-empty."
1810
+ });
1811
+ }
1812
+ const artifactId = input.artifactId.trim();
1813
+ if (!artifactId) {
1814
+ throw createError({
1815
+ code: ERROR_CODES.INVALID_INPUT,
1816
+ message: "artifactId must be non-empty."
1817
+ });
1818
+ }
1819
+ // Verify artifact exists
1820
+ this.getArtifact(artifactId);
1821
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 200));
1822
+ const warnings = [];
1823
+ const isQualified = className.includes(".");
1824
+ if (isQualified) {
1825
+ // Qualified name: fetch a broad candidate set first, then filter to exact class path/FQCN.
1826
+ // Limiting before filtering can miss the target when many packages share the same simple name.
1827
+ const classPath = className.replace(/\./g, "/");
1828
+ const result = this.symbolsRepo.findScopedSymbols({
1829
+ artifactId,
1830
+ query: className.split(".").at(-1) ?? className,
1831
+ match: "exact",
1832
+ limit: 5000
1833
+ });
1834
+ const matches = result.items
1835
+ .filter((row) => {
1836
+ const isTypeSymbol = row.symbolKind === "class" || row.symbolKind === "interface" ||
1837
+ row.symbolKind === "enum" || row.symbolKind === "record";
1838
+ if (!isTypeSymbol)
1839
+ return false;
1840
+ const rowQualified = row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
1841
+ return rowQualified === className || row.filePath === `${classPath}.java`;
1842
+ })
1843
+ .map((row) => ({
1844
+ qualifiedName: row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", "."),
1845
+ filePath: row.filePath,
1846
+ line: row.line,
1847
+ symbolKind: row.symbolKind
1848
+ }))
1849
+ .slice(0, limit);
1850
+ return { matches, total: matches.length, warnings };
1851
+ }
1852
+ // Simple name: search for exact symbol name match among type symbols
1853
+ const result = this.symbolsRepo.findScopedSymbols({
1854
+ artifactId,
1855
+ query: className,
1856
+ match: "exact",
1857
+ limit: limit * 5 // over-fetch to filter by kind
1858
+ });
1859
+ const matches = [];
1860
+ for (const row of result.items) {
1861
+ if (matches.length >= limit)
1862
+ break;
1863
+ const isTypeSymbol = row.symbolKind === "class" || row.symbolKind === "interface" ||
1864
+ row.symbolKind === "enum" || row.symbolKind === "record";
1865
+ if (!isTypeSymbol)
1866
+ continue;
1867
+ matches.push({
1868
+ qualifiedName: row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", "."),
1869
+ filePath: row.filePath,
1870
+ line: row.line,
1871
+ symbolKind: row.symbolKind
1872
+ });
1873
+ }
1874
+ return { matches, total: matches.length, warnings };
1875
+ }
1493
1876
  async getClassSource(input) {
1494
1877
  const className = input.className.trim();
1495
1878
  if (!className) {
@@ -1498,9 +1881,16 @@ export class SourceService {
1498
1881
  message: "className must be non-empty."
1499
1882
  });
1500
1883
  }
1884
+ const mode = input.mode ?? "metadata";
1501
1885
  const startLine = normalizeStrictPositiveInt(input.startLine, "startLine");
1502
1886
  const endLine = normalizeStrictPositiveInt(input.endLine, "endLine");
1503
- const maxLines = normalizeStrictPositiveInt(input.maxLines, "maxLines");
1887
+ let maxLines = normalizeStrictPositiveInt(input.maxLines, "maxLines");
1888
+ const maxChars = normalizeStrictPositiveInt(input.maxChars, "maxChars");
1889
+ const outputFile = normalizeOptionalString(input.outputFile);
1890
+ // In snippet mode, default maxLines to 200 when no range or maxLines is specified
1891
+ if (mode === "snippet" && startLine == null && endLine == null && maxLines == null) {
1892
+ maxLines = 200;
1893
+ }
1504
1894
  if (startLine != null && endLine != null && startLine > endLine) {
1505
1895
  throw createError({
1506
1896
  code: ERROR_CODES.INVALID_LINE_RANGE,
@@ -1540,7 +1930,11 @@ export class SourceService {
1540
1930
  target: input.target,
1541
1931
  mapping: input.mapping,
1542
1932
  sourcePriority: input.sourcePriority,
1543
- allowDecompile: input.allowDecompile
1933
+ allowDecompile: input.allowDecompile,
1934
+ projectPath: input.projectPath,
1935
+ scope: input.scope,
1936
+ preferProjectVersion: input.preferProjectVersion,
1937
+ strictVersion: input.strictVersion
1544
1938
  });
1545
1939
  artifactId = resolved.artifactId;
1546
1940
  origin = resolved.origin;
@@ -1560,41 +1954,100 @@ export class SourceService {
1560
1954
  }
1561
1955
  const filePath = this.resolveClassFilePath(artifactId, className);
1562
1956
  if (!filePath) {
1957
+ const simpleName = className.split(/[.$]/).at(-1) ?? className;
1958
+ const targetKind = input.target?.kind;
1959
+ const targetValue = input.target?.value;
1960
+ const scope = input.scope;
1961
+ let nextAction = `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`;
1962
+ if (targetKind === "version" && scope && scope !== "merged" && !input.projectPath) {
1963
+ nextAction +=
1964
+ ` If the class exists in a modded environment, retry with scope: "merged" and projectPath pointing to your mod project.`;
1965
+ }
1966
+ else if (targetKind === "version" && scope && scope !== "merged" && input.projectPath) {
1967
+ nextAction +=
1968
+ ` The class may exist in merged sources; retry with scope: "merged".`;
1969
+ }
1563
1970
  throw createError({
1564
1971
  code: ERROR_CODES.CLASS_NOT_FOUND,
1565
1972
  message: `Source for class "${className}" was not found.`,
1566
1973
  details: {
1567
1974
  artifactId,
1568
- className
1975
+ className,
1976
+ ...(scope ? { scope } : {}),
1977
+ ...(targetKind ? { targetKind } : {}),
1978
+ ...(targetValue ? { targetValue } : {}),
1979
+ mapping: mappingApplied,
1980
+ nextAction,
1981
+ suggestedCall: { tool: "find-class", params: { className: simpleName, artifactId } }
1569
1982
  }
1570
1983
  });
1571
1984
  }
1572
1985
  const row = this.filesRepo.getFileContent(artifactId, filePath);
1573
1986
  if (!row) {
1987
+ const simpleName = className.split(/[.$]/).at(-1) ?? className;
1574
1988
  throw createError({
1575
1989
  code: ERROR_CODES.CLASS_NOT_FOUND,
1576
1990
  message: `Source for class "${className}" was not found.`,
1577
1991
  details: {
1578
1992
  artifactId,
1579
1993
  className,
1580
- filePath
1994
+ filePath,
1995
+ ...(input.scope ? { scope: input.scope } : {}),
1996
+ ...(input.target?.kind ? { targetKind: input.target.kind } : {}),
1997
+ ...(input.target?.value ? { targetValue: input.target.value } : {}),
1998
+ nextAction: `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`,
1999
+ suggestedCall: { tool: "find-class", params: { className: simpleName, artifactId } }
1581
2000
  }
1582
2001
  });
1583
2002
  }
1584
2003
  const lines = row.content.split(/\r?\n/);
1585
2004
  const totalLines = lines.length;
1586
- const requestedStart = startLine ?? 1;
1587
- const requestedEnd = endLine ?? totalLines;
1588
- const normalizedStart = Math.min(Math.max(1, requestedStart), Math.max(totalLines, 1));
1589
- const normalizedEnd = Math.min(Math.max(normalizedStart, requestedEnd), Math.max(totalLines, 1));
1590
- let selectedLines = lines.slice(normalizedStart - 1, normalizedEnd);
1591
- const clippedByRange = normalizedStart !== requestedStart || normalizedEnd !== requestedEnd;
1592
- let clippedByMax = false;
1593
- if (maxLines != null && selectedLines.length > maxLines) {
1594
- selectedLines = selectedLines.slice(0, maxLines);
1595
- clippedByMax = true;
1596
- }
1597
- const returnedEnd = normalizedStart + Math.max(0, selectedLines.length - 1);
2005
+ let sourceText;
2006
+ let returnedStart;
2007
+ let returnedEnd;
2008
+ let truncated = false;
2009
+ let charsTruncated = false;
2010
+ if (mode === "metadata") {
2011
+ const metadataText = this.extractClassMetadata(filePath, row.content);
2012
+ sourceText = metadataText;
2013
+ returnedStart = 1;
2014
+ returnedEnd = totalLines;
2015
+ truncated = false;
2016
+ }
2017
+ else {
2018
+ // snippet and full modes use the existing line-range logic
2019
+ const requestedStart = startLine ?? 1;
2020
+ const requestedEnd = endLine ?? totalLines;
2021
+ const normalizedStart = Math.min(Math.max(1, requestedStart), Math.max(totalLines, 1));
2022
+ const normalizedEnd = Math.min(Math.max(normalizedStart, requestedEnd), Math.max(totalLines, 1));
2023
+ let selectedLines = lines.slice(normalizedStart - 1, normalizedEnd);
2024
+ const clippedByRange = normalizedStart !== requestedStart || normalizedEnd !== requestedEnd;
2025
+ let clippedByMax = false;
2026
+ if (maxLines != null && selectedLines.length > maxLines) {
2027
+ selectedLines = selectedLines.slice(0, maxLines);
2028
+ clippedByMax = true;
2029
+ }
2030
+ sourceText = selectedLines.join("\n");
2031
+ returnedStart = normalizedStart;
2032
+ returnedEnd = normalizedStart + Math.max(0, selectedLines.length - 1);
2033
+ truncated = clippedByRange || clippedByMax;
2034
+ }
2035
+ // Apply maxChars truncation
2036
+ if (maxChars != null && sourceText.length > maxChars) {
2037
+ sourceText = sourceText.slice(0, maxChars);
2038
+ charsTruncated = true;
2039
+ truncated = true;
2040
+ }
2041
+ // Write to file if outputFile is specified
2042
+ let resolvedOutputFile;
2043
+ if (outputFile) {
2044
+ const outputPath = isAbsolute(outputFile)
2045
+ ? outputFile
2046
+ : resolvePath(outputFile);
2047
+ await writeFile(outputPath, sourceText, "utf8");
2048
+ resolvedOutputFile = outputPath;
2049
+ sourceText = `[Written to ${outputPath}]`;
2050
+ }
1598
2051
  const normalizedProvenance = provenance ??
1599
2052
  this.buildFallbackProvenance({
1600
2053
  artifactId,
@@ -1604,19 +2057,22 @@ export class SourceService {
1604
2057
  });
1605
2058
  return {
1606
2059
  className,
1607
- sourceText: selectedLines.join("\n"),
2060
+ mode,
2061
+ sourceText,
1608
2062
  totalLines,
1609
2063
  returnedRange: {
1610
- start: normalizedStart,
2064
+ start: returnedStart,
1611
2065
  end: returnedEnd
1612
2066
  },
1613
- truncated: clippedByRange || clippedByMax,
2067
+ truncated,
2068
+ ...(charsTruncated ? { charsTruncated } : {}),
1614
2069
  origin,
1615
2070
  artifactId,
1616
2071
  requestedMapping,
1617
2072
  mappingApplied,
1618
2073
  provenance: normalizedProvenance,
1619
2074
  qualityFlags,
2075
+ ...(resolvedOutputFile ? { outputFile: resolvedOutputFile } : {}),
1620
2076
  warnings
1621
2077
  };
1622
2078
  }
@@ -1668,7 +2124,11 @@ export class SourceService {
1668
2124
  target: input.target,
1669
2125
  mapping: requestedMapping,
1670
2126
  sourcePriority: input.sourcePriority,
1671
- allowDecompile: input.allowDecompile
2127
+ allowDecompile: input.allowDecompile,
2128
+ projectPath: input.projectPath,
2129
+ scope: input.scope,
2130
+ preferProjectVersion: input.preferProjectVersion,
2131
+ strictVersion: input.strictVersion
1672
2132
  });
1673
2133
  artifactId = resolved.artifactId;
1674
2134
  origin = resolved.origin;
@@ -1694,7 +2154,8 @@ export class SourceService {
1694
2154
  message: `Non-official mapping "${requestedMapping}" requires a version, but none was resolved.`,
1695
2155
  details: {
1696
2156
  mapping: requestedMapping,
1697
- nextAction: "Resolve with targetKind=version or specify a versioned coordinate."
2157
+ nextAction: "Resolve with targetKind=version or specify a versioned coordinate.",
2158
+ suggestedCall: { tool: "resolve-artifact", params: { targetKind: "version", targetValue: "latest" } }
1698
2159
  }
1699
2160
  });
1700
2161
  }
@@ -1722,13 +2183,13 @@ export class SourceService {
1722
2183
  });
1723
2184
  warnings.push(...signature.warnings);
1724
2185
  let remappedConstructors = version != null
1725
- ? await this.remapSignatureMembers(signature.constructors, "method", version, requestedMapping, input.sourcePriority, warnings)
2186
+ ? (await this.remapSignatureMembers(signature.constructors, "method", version, requestedMapping, input.sourcePriority, warnings)).members
1726
2187
  : signature.constructors;
1727
2188
  let remappedFields = version != null
1728
- ? await this.remapSignatureMembers(signature.fields, "field", version, requestedMapping, input.sourcePriority, warnings)
2189
+ ? (await this.remapSignatureMembers(signature.fields, "field", version, requestedMapping, input.sourcePriority, warnings)).members
1729
2190
  : signature.fields;
1730
2191
  let remappedMethods = version != null
1731
- ? await this.remapSignatureMembers(signature.methods, "method", version, requestedMapping, input.sourcePriority, warnings)
2192
+ ? (await this.remapSignatureMembers(signature.methods, "method", version, requestedMapping, input.sourcePriority, warnings)).members
1732
2193
  : signature.methods;
1733
2194
  // Apply memberPattern post-remap for non-official mappings
1734
2195
  if (requestedMapping !== "official" && memberPattern) {
@@ -1787,40 +2248,272 @@ export class SourceService {
1787
2248
  };
1788
2249
  }
1789
2250
  async validateMixin(input) {
1790
- const version = input.version.trim();
2251
+ // Mixin config mode: read JSON config(s) and derive sourcePaths
2252
+ if (input.mixinConfigPath) {
2253
+ const configPaths = Array.isArray(input.mixinConfigPath) ? input.mixinConfigPath : [input.mixinConfigPath];
2254
+ const allSourcePaths = [];
2255
+ for (const rawConfigPath of configPaths) {
2256
+ const normalizedConfigPath = normalizePathForHost(rawConfigPath, undefined, "mixinConfigPath");
2257
+ const resolvedConfigPath = isAbsolute(normalizedConfigPath)
2258
+ ? normalizedConfigPath
2259
+ : resolvePath(process.cwd(), normalizedConfigPath);
2260
+ let configJson;
2261
+ try {
2262
+ const raw = await readFile(resolvedConfigPath, "utf-8");
2263
+ configJson = JSON.parse(raw);
2264
+ }
2265
+ catch (err) {
2266
+ throw createError({
2267
+ code: ERROR_CODES.INVALID_INPUT,
2268
+ message: `Could not read/parse mixinConfigPath "${rawConfigPath}": ${err instanceof Error ? err.message : String(err)}`
2269
+ });
2270
+ }
2271
+ const pkg = configJson.package ?? "";
2272
+ const classNames = [
2273
+ ...(configJson.mixins ?? []),
2274
+ ...(configJson.client ?? []),
2275
+ ...(configJson.server ?? [])
2276
+ ];
2277
+ if (classNames.length === 0) {
2278
+ continue; // Skip empty configs in array mode
2279
+ }
2280
+ // Determine source root(s)
2281
+ const projectBase = input.projectPath
2282
+ ? (isAbsolute(input.projectPath) ? input.projectPath : resolvePath(process.cwd(), input.projectPath))
2283
+ : dirname(resolvedConfigPath);
2284
+ let sourceRootCandidates;
2285
+ if (input.sourceRoots && input.sourceRoots.length > 0) {
2286
+ sourceRootCandidates = input.sourceRoots;
2287
+ }
2288
+ else if (input.sourceRoot) {
2289
+ sourceRootCandidates = [input.sourceRoot];
2290
+ }
2291
+ else {
2292
+ // Auto-detect: include any root that contains at least one configured mixin class.
2293
+ const detected = COMMON_SOURCE_ROOTS.filter((candidateRoot) => classNames.some((className) => {
2294
+ const fqcn = pkg ? `${pkg}.${className}` : className;
2295
+ const relative = fqcn.replace(/\./g, "/") + ".java";
2296
+ return existsSync(resolvePath(projectBase, candidateRoot, relative));
2297
+ }));
2298
+ if (detected.length > 0) {
2299
+ sourceRootCandidates = detected;
2300
+ }
2301
+ else {
2302
+ sourceRootCandidates = ["src/main/java"];
2303
+ }
2304
+ }
2305
+ // Build sourcePaths by probing each class against candidate roots
2306
+ for (const cls of classNames) {
2307
+ const fqcn = pkg ? `${pkg}.${cls}` : cls;
2308
+ const relativePath = fqcn.replace(/\./g, "/") + ".java";
2309
+ let found = false;
2310
+ for (const root of sourceRootCandidates) {
2311
+ const candidate = resolvePath(projectBase, root, relativePath);
2312
+ if (existsSync(candidate)) {
2313
+ allSourcePaths.push(candidate);
2314
+ found = true;
2315
+ break;
2316
+ }
2317
+ }
2318
+ if (!found) {
2319
+ // Fallback to first root for error reporting
2320
+ allSourcePaths.push(resolvePath(projectBase, sourceRootCandidates[0], relativePath));
2321
+ }
2322
+ }
2323
+ }
2324
+ if (allSourcePaths.length === 0) {
2325
+ throw createError({
2326
+ code: ERROR_CODES.INVALID_INPUT,
2327
+ message: `Mixin config(s) contain no mixin class entries.`
2328
+ });
2329
+ }
2330
+ return this.validateMixinBatch({ ...input, mixinConfigPath: undefined, sourcePaths: allSourcePaths });
2331
+ }
2332
+ // Batch mode: delegate to validateMixinBatch when sourcePaths is provided
2333
+ if (input.sourcePaths && input.sourcePaths.length > 0) {
2334
+ return this.validateMixinBatch(input);
2335
+ }
2336
+ let version = input.version.trim();
1791
2337
  if (!version) {
1792
2338
  throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "version must be non-empty." });
1793
2339
  }
1794
- const source = input.source;
2340
+ // Resolve source from source or sourcePath
2341
+ let source;
2342
+ if (input.sourcePath) {
2343
+ const normalizedSourcePath = normalizePathForHost(input.sourcePath, undefined, "sourcePath");
2344
+ const resolvedSourcePath = isAbsolute(normalizedSourcePath)
2345
+ ? normalizedSourcePath
2346
+ : resolvePath(process.cwd(), normalizedSourcePath);
2347
+ try {
2348
+ source = await readFile(resolvedSourcePath, "utf-8");
2349
+ }
2350
+ catch (err) {
2351
+ throw createError({
2352
+ code: ERROR_CODES.INVALID_INPUT,
2353
+ message: `Could not read sourcePath "${input.sourcePath}" (resolved to "${resolvedSourcePath}"):` +
2354
+ ` ${err instanceof Error ? err.message : String(err)}`
2355
+ });
2356
+ }
2357
+ }
2358
+ else {
2359
+ source = input.source ?? "";
2360
+ }
1795
2361
  if (!source.trim()) {
1796
2362
  throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "source must be non-empty." });
1797
2363
  }
1798
2364
  const warnings = [];
1799
- const requestedMapping = normalizeMapping(input.mapping);
1800
- const { jarPath } = await this.versionService.resolveVersionJar(version);
2365
+ let mappingAutoDetected = false;
2366
+ // Auto-detect mapping from project config when not explicitly provided (or when preferProjectMapping is set)
2367
+ let detectedMapping;
2368
+ if ((!input.mapping || input.preferProjectMapping) && input.projectPath) {
2369
+ try {
2370
+ const detection = await this.workspaceMappingService.detectCompileMapping({ projectPath: input.projectPath });
2371
+ if (detection.resolved && detection.mappingApplied) {
2372
+ detectedMapping = detection.mappingApplied;
2373
+ mappingAutoDetected = true;
2374
+ warnings.push(`Auto-detected mapping '${detectedMapping}' from project configuration.`);
2375
+ warnings.push(...detection.warnings);
2376
+ }
2377
+ else {
2378
+ warnings.push(...detection.warnings);
2379
+ }
2380
+ }
2381
+ catch {
2382
+ // Detection failed — fall through to default
2383
+ }
2384
+ }
2385
+ const requestedMapping = normalizeMapping(detectedMapping ?? input.mapping);
2386
+ let mappingApplied = requestedMapping;
2387
+ // preferProjectVersion: detect MC version from gradle.properties
2388
+ if (input.preferProjectVersion && input.projectPath) {
2389
+ const detected = await this.workspaceMappingService.detectProjectMinecraftVersion(input.projectPath);
2390
+ if (detected && detected !== version) {
2391
+ warnings.push(`Overriding version "${version}" with project version "${detected}" from gradle.properties.`);
2392
+ }
2393
+ version = detected ?? version;
2394
+ }
2395
+ // Resolve jar: use Loom cache for non-vanilla scope with projectPath
2396
+ let jarPath;
2397
+ let scopeFallback;
2398
+ if (input.scope && input.scope !== "vanilla" && input.projectPath) {
2399
+ try {
2400
+ const resolved = await this.resolveArtifact({
2401
+ target: { kind: "version", value: version },
2402
+ mapping: requestedMapping,
2403
+ sourcePriority: input.sourcePriority,
2404
+ projectPath: input.projectPath,
2405
+ scope: input.scope,
2406
+ preferProjectVersion: false
2407
+ });
2408
+ jarPath = resolved.binaryJarPath ?? (await this.versionService.resolveVersionJar(version)).jarPath;
2409
+ warnings.push(...resolved.warnings);
2410
+ mappingApplied = resolved.mappingApplied;
2411
+ if (resolved.version) {
2412
+ version = resolved.version;
2413
+ }
2414
+ }
2415
+ catch (scopeErr) {
2416
+ // Scope preflight failed — fall back to vanilla
2417
+ scopeFallback = {
2418
+ requested: input.scope,
2419
+ applied: "vanilla",
2420
+ reason: `Loom cache unavailable: ${scopeErr instanceof Error ? scopeErr.message : String(scopeErr)}`
2421
+ };
2422
+ warnings.push(`Scope "${input.scope}" resolution failed; falling back to vanilla. ${scopeFallback.reason}`);
2423
+ jarPath = (await this.versionService.resolveVersionJar(version)).jarPath;
2424
+ }
2425
+ }
2426
+ else {
2427
+ jarPath = (await this.versionService.resolveVersionJar(version)).jarPath;
2428
+ }
2429
+ // Guard: reject sources jars — they contain Java source, not bytecode
2430
+ if (jarPath.includes("-sources.jar")) {
2431
+ warnings.push(`Resolved jar appears to be a sources jar. Falling back to vanilla client jar.`);
2432
+ jarPath = (await this.versionService.resolveVersionJar(version)).jarPath;
2433
+ scopeFallback = {
2434
+ requested: input.scope ?? "vanilla",
2435
+ applied: "vanilla",
2436
+ reason: "Resolved jar was a sources jar, not a binary class jar."
2437
+ };
2438
+ }
2439
+ // Health check: probe mapping infrastructure
2440
+ let healthReport;
2441
+ try {
2442
+ const health = await this.mappingService.checkMappingHealth({
2443
+ version,
2444
+ requestedMapping,
2445
+ sourcePriority: input.sourcePriority
2446
+ });
2447
+ const jarAvailable = existsSync(jarPath);
2448
+ healthReport = {
2449
+ jarAvailable,
2450
+ jarPath,
2451
+ mojangMappingsAvailable: health.mojangMappingsAvailable,
2452
+ tinyMappingsAvailable: health.tinyMappingsAvailable,
2453
+ memberRemapAvailable: health.memberRemapAvailable,
2454
+ overallHealthy: jarAvailable && health.mojangMappingsAvailable,
2455
+ degradations: [
2456
+ ...(jarAvailable ? [] : ["Game jar not found."]),
2457
+ ...health.degradations
2458
+ ]
2459
+ };
2460
+ }
2461
+ catch {
2462
+ // Health check failed — proceed without it
2463
+ }
1801
2464
  const parsed = parseMixinSource(source);
1802
2465
  const targetMembers = new Map();
2466
+ const mappingFailedTargets = new Set();
2467
+ const remapFailedMembers = new Map();
2468
+ const signatureFailedTargets = new Set();
2469
+ const symbolExistsButSignatureFailed = new Set();
2470
+ const resolutionTrace = input.explain ? [] : undefined;
1803
2471
  for (const target of parsed.targets) {
1804
- let officialName = target.className;
2472
+ // Bug 1 fix: resolve simple names via imports
2473
+ let resolvedClassName = target.className;
2474
+ if (!resolvedClassName.includes(".")) {
2475
+ // Simple name — look up in imports
2476
+ const fqcn = parsed.imports.get(resolvedClassName);
2477
+ if (fqcn) {
2478
+ resolvedClassName = fqcn;
2479
+ }
2480
+ }
2481
+ else {
2482
+ // Might be inner class like Foo.Bar where Foo is imported
2483
+ const segments = resolvedClassName.split(".");
2484
+ const firstSegment = segments[0];
2485
+ if (firstSegment && /^[A-Z]/.test(firstSegment)) {
2486
+ const outerFqcn = parsed.imports.get(firstSegment);
2487
+ if (outerFqcn) {
2488
+ resolvedClassName = outerFqcn + "$" + segments.slice(1).join("$");
2489
+ }
2490
+ }
2491
+ }
2492
+ let officialName = resolvedClassName;
1805
2493
  if (requestedMapping !== "official") {
1806
2494
  try {
1807
2495
  const mapped = await this.mappingService.findMapping({
1808
2496
  version,
1809
2497
  kind: "class",
1810
- name: target.className,
2498
+ name: resolvedClassName,
1811
2499
  sourceMapping: requestedMapping,
1812
2500
  targetMapping: "official",
1813
2501
  sourcePriority: input.sourcePriority
1814
2502
  });
1815
2503
  if (mapped.resolved && mapped.resolvedSymbol) {
1816
2504
  officialName = mapped.resolvedSymbol.name;
2505
+ resolutionTrace?.push({ target: target.className, step: "mapping", input: resolvedClassName, output: officialName, success: true });
1817
2506
  }
1818
2507
  else {
1819
- warnings.push(`Could not map class "${target.className}" from ${requestedMapping} to official.`);
2508
+ warnings.push(`Could not map class "${resolvedClassName}" from ${requestedMapping} to official; using "${officialName}" for lookup.`);
2509
+ mappingFailedTargets.add(target.className);
2510
+ resolutionTrace?.push({ target: target.className, step: "mapping", input: resolvedClassName, output: officialName, success: false, detail: "No mapping found" });
1820
2511
  }
1821
2512
  }
1822
- catch {
1823
- warnings.push(`Mapping lookup failed for class "${target.className}".`);
2513
+ catch (mapErr) {
2514
+ warnings.push(`Mapping lookup failed for class "${resolvedClassName}"; using "${officialName}" for lookup.`);
2515
+ mappingFailedTargets.add(target.className);
2516
+ resolutionTrace?.push({ target: target.className, step: "mapping", input: resolvedClassName, output: officialName, success: false, detail: mapErr instanceof Error ? mapErr.message : String(mapErr) });
1824
2517
  }
1825
2518
  }
1826
2519
  try {
@@ -1830,18 +2523,288 @@ export class SourceService {
1830
2523
  access: "all"
1831
2524
  });
1832
2525
  warnings.push(...sig.warnings);
2526
+ resolutionTrace?.push({ target: target.className, step: "signature", input: officialName, output: `${sig.methods.length} methods, ${sig.fields.length} fields`, success: true });
2527
+ // Bug 2 fix: remap signature members to requested mapping
2528
+ let constructors = sig.constructors;
2529
+ let methods = sig.methods;
2530
+ let fields = sig.fields;
2531
+ if (requestedMapping !== "official") {
2532
+ try {
2533
+ const [ctorResult, methodResult, fieldResult] = await Promise.all([
2534
+ this.remapSignatureMembers(sig.constructors, "method", version, requestedMapping, input.sourcePriority, warnings),
2535
+ this.remapSignatureMembers(sig.methods, "method", version, requestedMapping, input.sourcePriority, warnings),
2536
+ this.remapSignatureMembers(sig.fields, "field", version, requestedMapping, input.sourcePriority, warnings)
2537
+ ]);
2538
+ constructors = ctorResult.members;
2539
+ methods = methodResult.members;
2540
+ fields = fieldResult.members;
2541
+ // Collect remap-failed member names for this target
2542
+ const targetFailed = new Set();
2543
+ for (const n of ctorResult.failedNames)
2544
+ targetFailed.add(n);
2545
+ for (const n of methodResult.failedNames)
2546
+ targetFailed.add(n);
2547
+ for (const n of fieldResult.failedNames)
2548
+ targetFailed.add(n);
2549
+ if (targetFailed.size > 0) {
2550
+ remapFailedMembers.set(target.className, targetFailed);
2551
+ resolutionTrace?.push({ target: target.className, step: "remap", input: `${targetFailed.size} members`, output: "failed", success: false });
2552
+ }
2553
+ else {
2554
+ resolutionTrace?.push({ target: target.className, step: "remap", input: `${methods.length + fields.length} members`, output: "remapped", success: true });
2555
+ }
2556
+ }
2557
+ catch (remapErr) {
2558
+ warnings.push(`Member remapping failed for "${resolvedClassName}"; falling back to official names. Member names shown may be in official (obfuscated) namespace.`);
2559
+ mappingApplied = "official";
2560
+ resolutionTrace?.push({ target: target.className, step: "remap", input: resolvedClassName, output: "official fallback", success: false, detail: remapErr instanceof Error ? remapErr.message : String(remapErr) });
2561
+ }
2562
+ }
1833
2563
  targetMembers.set(target.className, {
1834
2564
  className: target.className,
1835
- constructors: sig.constructors,
1836
- methods: sig.methods,
1837
- fields: sig.fields
2565
+ constructors,
2566
+ methods,
2567
+ fields
1838
2568
  });
1839
2569
  }
1840
- catch {
1841
- warnings.push(`Could not load signature for class "${officialName}".`);
2570
+ catch (sigErr) {
2571
+ warnings.push(`Could not load signature for class "${resolvedClassName}" (official: "${officialName}").`);
2572
+ signatureFailedTargets.add(target.className);
2573
+ resolutionTrace?.push({ target: target.className, step: "signature", input: officialName, output: "CLASS_NOT_FOUND", success: false, detail: sigErr instanceof Error ? sigErr.message : String(sigErr) });
2574
+ // Fallback: check if the symbol exists in the mapping graph even though getSignature failed
2575
+ try {
2576
+ const existenceCheck = await this.mappingService.checkSymbolExists({
2577
+ version, kind: "class", name: resolvedClassName,
2578
+ sourceMapping: requestedMapping, nameMode: "auto"
2579
+ });
2580
+ if (existenceCheck.resolved) {
2581
+ signatureFailedTargets.delete(target.className);
2582
+ symbolExistsButSignatureFailed.add(target.className);
2583
+ resolutionTrace?.push({ target: target.className, step: "fallback-check", input: resolvedClassName, output: "exists in mapping graph", success: true });
2584
+ }
2585
+ else {
2586
+ resolutionTrace?.push({ target: target.className, step: "fallback-check", input: resolvedClassName, output: "not found", success: false });
2587
+ }
2588
+ }
2589
+ catch {
2590
+ // Fallback check failed — keep as signatureFailedTarget
2591
+ resolutionTrace?.push({ target: target.className, step: "fallback-check", input: resolvedClassName, output: "check failed", success: false });
2592
+ }
2593
+ }
2594
+ }
2595
+ // Fix toolHealth accuracy: reflect actual failures after target resolution
2596
+ if (healthReport) {
2597
+ const hasFailures = signatureFailedTargets.size > 0 || mappingFailedTargets.size > 0;
2598
+ if (hasFailures && healthReport.overallHealthy) {
2599
+ healthReport.overallHealthy = false;
2600
+ healthReport.degradations.push(`${mappingFailedTargets.size} mapping failure(s), ${signatureFailedTargets.size} signature failure(s).`);
2601
+ }
2602
+ }
2603
+ const resolutionNotes = [];
2604
+ if (requestedMapping !== mappingApplied) {
2605
+ resolutionNotes.push(`Mapping fallback: requested "${requestedMapping}" but applied "${mappingApplied}" due to remapping failure.`);
2606
+ }
2607
+ // Count remap failures from warnings
2608
+ const REMAP_WARNING_RE = /^(?:Could not remap|Remap failed for)\b/;
2609
+ const remapFailures = warnings.filter((w) => REMAP_WARNING_RE.test(w)).length;
2610
+ // Determine confidence level
2611
+ let confidence = "definite";
2612
+ if (requestedMapping !== mappingApplied) {
2613
+ confidence = "uncertain";
2614
+ }
2615
+ else if (remapFailures > 0) {
2616
+ confidence = "likely";
2617
+ }
2618
+ // Build mapping chain description
2619
+ const mappingChain = [];
2620
+ if (requestedMapping !== "official") {
2621
+ mappingChain.push(`${requestedMapping} → official`);
2622
+ if (mappingApplied !== requestedMapping) {
2623
+ mappingChain.push(`fallback to ${mappingApplied}`);
2624
+ }
2625
+ }
2626
+ const provenance = {
2627
+ version,
2628
+ jarPath,
2629
+ requestedMapping,
2630
+ mappingApplied,
2631
+ resolutionNotes: resolutionNotes.length > 0 ? resolutionNotes : undefined,
2632
+ jarType: scopeFallback ? "vanilla-client" : (input.scope && input.scope !== "vanilla" && input.projectPath) ? "merged" : "vanilla-client",
2633
+ mappingChain: mappingChain.length > 0 ? mappingChain : undefined,
2634
+ remapFailures: remapFailures > 0 ? remapFailures : undefined,
2635
+ mappingAutoDetected: mappingAutoDetected || undefined,
2636
+ scopeFallback,
2637
+ resolutionTrace: resolutionTrace && resolutionTrace.length > 0 ? resolutionTrace : undefined
2638
+ };
2639
+ const result = validateParsedMixin(parsed, targetMembers, warnings, provenance, confidence, mappingFailedTargets, input.explain, remapFailedMembers, signatureFailedTargets, input.explain ? { scope: input.scope, sourcePriority: input.sourcePriority, projectPath: input.projectPath, mapping: requestedMapping } : undefined, input.warningMode, healthReport, symbolExistsButSignatureFailed.size > 0 ? symbolExistsButSignatureFailed : undefined);
2640
+ // Apply minSeverity / hideUncertain filters
2641
+ const minSeverity = input.minSeverity ?? "all";
2642
+ const hideUncertain = input.hideUncertain ?? false;
2643
+ if (minSeverity !== "all" || hideUncertain) {
2644
+ const unfilteredSummary = { ...result.summary };
2645
+ let filtered = result.issues;
2646
+ if (minSeverity === "error") {
2647
+ filtered = filtered.filter((i) => i.severity === "error");
2648
+ }
2649
+ else if (minSeverity === "warning") {
2650
+ filtered = filtered.filter((i) => i.severity === "error" || i.severity === "warning");
2651
+ }
2652
+ if (hideUncertain) {
2653
+ filtered = filtered.filter((i) => i.confidence !== "uncertain");
2654
+ }
2655
+ const filteredErrors = filtered.filter((i) => i.severity === "error").length;
2656
+ const filteredWarnings = filtered.filter((i) => i.severity === "warning").length;
2657
+ const filteredDefiniteErrors = filtered.filter((i) => i.severity === "error" && i.confidence !== "uncertain").length;
2658
+ const filteredUncertainErrors = filtered.filter((i) => i.severity === "error" && i.confidence === "uncertain").length;
2659
+ const filteredResolutionErrors = filtered.filter((i) => i.resolutionPath != null).length;
2660
+ const filteredParseWarnings = filtered.filter((i) => i.category === "parse").length;
2661
+ result.issues = filtered;
2662
+ result.summary = {
2663
+ ...result.summary,
2664
+ errors: filteredErrors,
2665
+ warnings: filteredWarnings,
2666
+ definiteErrors: filteredDefiniteErrors,
2667
+ uncertainErrors: filteredUncertainErrors,
2668
+ resolutionErrors: filteredResolutionErrors,
2669
+ parseWarnings: filteredParseWarnings
2670
+ };
2671
+ result.unfilteredSummary = unfilteredSummary;
2672
+ result.valid = filteredDefiniteErrors === 0;
2673
+ }
2674
+ // Apply warningCategoryFilter
2675
+ if (input.warningCategoryFilter && input.warningCategoryFilter.length > 0) {
2676
+ const allowedCategories = new Set(input.warningCategoryFilter);
2677
+ result.issues = result.issues.filter((i) => i.category && allowedCategories.has(i.category));
2678
+ if (result.structuredWarnings) {
2679
+ result.structuredWarnings = result.structuredWarnings.filter((sw) => sw.category && allowedCategories.has(sw.category));
2680
+ if (result.structuredWarnings.length === 0)
2681
+ result.structuredWarnings = undefined;
2682
+ }
2683
+ // Re-compute summary after category filter
2684
+ const catErrors = result.issues.filter((i) => i.severity === "error").length;
2685
+ const catWarnings = result.issues.filter((i) => i.severity === "warning").length;
2686
+ const catDefiniteErrors = result.issues.filter((i) => i.severity === "error" && i.confidence !== "uncertain").length;
2687
+ result.summary = {
2688
+ ...result.summary,
2689
+ errors: catErrors,
2690
+ warnings: catWarnings,
2691
+ definiteErrors: catDefiniteErrors,
2692
+ uncertainErrors: result.issues.filter((i) => i.severity === "error" && i.confidence === "uncertain").length,
2693
+ resolutionErrors: result.issues.filter((i) => i.resolutionPath != null).length,
2694
+ parseWarnings: result.issues.filter((i) => i.category === "parse").length
2695
+ };
2696
+ result.valid = catDefiniteErrors === 0;
2697
+ }
2698
+ // Apply treatInfoAsWarning filter
2699
+ if (input.treatInfoAsWarning === false && result.structuredWarnings) {
2700
+ result.structuredWarnings = result.structuredWarnings.filter((sw) => sw.severity !== "info");
2701
+ if (result.structuredWarnings.length === 0)
2702
+ result.structuredWarnings = undefined;
2703
+ }
2704
+ // Apply compact report mode
2705
+ if (input.reportMode === "compact") {
2706
+ result.resolvedMembers = undefined;
2707
+ result.structuredWarnings = undefined;
2708
+ result.aggregatedWarnings = undefined;
2709
+ result.toolHealth = undefined;
2710
+ if (result.provenance) {
2711
+ result.provenance.resolutionTrace = undefined;
2712
+ }
2713
+ }
2714
+ return result;
2715
+ }
2716
+ async validateMixinBatch(input) {
2717
+ const paths = input.sourcePaths;
2718
+ const results = [];
2719
+ let validCount = 0;
2720
+ let invalidCount = 0;
2721
+ let errorCount = 0;
2722
+ // P5: default warningMode to "aggregated" in batch mode
2723
+ const batchWarningMode = input.warningMode ?? "aggregated";
2724
+ for (const sp of paths) {
2725
+ try {
2726
+ const singleResult = await this.validateMixin({
2727
+ ...input,
2728
+ sourcePaths: undefined,
2729
+ source: undefined,
2730
+ sourcePath: sp,
2731
+ warningMode: batchWarningMode
2732
+ });
2733
+ results.push({ sourcePath: sp, result: singleResult });
2734
+ if (singleResult.valid) {
2735
+ validCount++;
2736
+ }
2737
+ else {
2738
+ invalidCount++;
2739
+ }
2740
+ }
2741
+ catch (err) {
2742
+ results.push({
2743
+ sourcePath: sp,
2744
+ error: err instanceof Error ? err.message : String(err)
2745
+ });
2746
+ errorCount++;
2747
+ }
2748
+ }
2749
+ // Build issueSummary: aggregate issues across all results by (kind, confidence, category)
2750
+ const issueGroupMap = new Map();
2751
+ for (const r of results) {
2752
+ if (!r.result)
2753
+ continue;
2754
+ for (const issue of r.result.issues) {
2755
+ const key = `${issue.kind}\0${issue.confidence ?? "unknown"}\0${issue.category ?? "validation"}`;
2756
+ const existing = issueGroupMap.get(key);
2757
+ if (existing) {
2758
+ existing.count++;
2759
+ if (existing.sampleTargets.length < 3) {
2760
+ existing.sampleTargets.push(issue.target);
2761
+ }
2762
+ }
2763
+ else {
2764
+ issueGroupMap.set(key, {
2765
+ kind: issue.kind,
2766
+ confidence: issue.confidence ?? "unknown",
2767
+ category: issue.category ?? "validation",
2768
+ count: 1,
2769
+ sampleTargets: [issue.target]
2770
+ });
2771
+ }
1842
2772
  }
1843
2773
  }
1844
- return validateParsedMixin(parsed, targetMembers, warnings);
2774
+ const issueSummary = issueGroupMap.size > 0
2775
+ ? [...issueGroupMap.values()]
2776
+ : undefined;
2777
+ // Aggregate validation-level errors/warnings across all results
2778
+ let totalValidationErrors = 0;
2779
+ let totalValidationWarnings = 0;
2780
+ for (const r of results) {
2781
+ if (r.result) {
2782
+ totalValidationErrors += r.result.summary.errors;
2783
+ totalValidationWarnings += r.result.summary.warnings;
2784
+ }
2785
+ }
2786
+ // Extract shared toolHealth from first result (all share same version/mapping)
2787
+ const sharedHealth = results.find((r) => r.result?.toolHealth)?.result?.toolHealth;
2788
+ // Batch confidenceScore = min of all individual scores
2789
+ const scores = results
2790
+ .map((r) => r.result?.confidenceScore)
2791
+ .filter((s) => s != null);
2792
+ const batchConfidenceScore = scores.length > 0 ? Math.min(...scores) : undefined;
2793
+ return {
2794
+ results,
2795
+ summary: {
2796
+ total: paths.length,
2797
+ valid: validCount,
2798
+ invalid: invalidCount,
2799
+ errors: errorCount,
2800
+ processingErrors: errorCount,
2801
+ totalValidationErrors,
2802
+ totalValidationWarnings,
2803
+ confidenceScore: batchConfidenceScore
2804
+ },
2805
+ issueSummary,
2806
+ toolHealth: sharedHealth
2807
+ };
1845
2808
  }
1846
2809
  async validateAccessWidener(input) {
1847
2810
  const version = input.version.trim();
@@ -2344,6 +3307,33 @@ export class SourceService {
2344
3307
  // Contains matches need more candidates
2345
3308
  return base;
2346
3309
  }
3310
+ extractClassMetadata(filePath, content) {
3311
+ const lines = content.split(/\r?\n/);
3312
+ const symbols = extractSymbolsFromSource(filePath, content);
3313
+ const outputParts = [];
3314
+ // Include package + import header (lines before first symbol declaration)
3315
+ const firstSymbolLine = symbols.length > 0 ? symbols[0].line : lines.length + 1;
3316
+ for (let i = 0; i < Math.min(firstSymbolLine - 1, lines.length); i++) {
3317
+ const line = lines[i];
3318
+ const trimmed = line.trim();
3319
+ if (trimmed.startsWith("package ") || trimmed.startsWith("import ") || trimmed === "") {
3320
+ outputParts.push(line);
3321
+ }
3322
+ }
3323
+ // Add each symbol's declaration line
3324
+ for (const symbol of symbols) {
3325
+ const lineIndex = symbol.line - 1;
3326
+ if (lineIndex >= 0 && lineIndex < lines.length) {
3327
+ const prefix = symbol.symbolKind === "class" || symbol.symbolKind === "interface" ||
3328
+ symbol.symbolKind === "enum" || symbol.symbolKind === "record"
3329
+ ? `\n// [${symbol.symbolKind}] line ${symbol.line}`
3330
+ : `// [${symbol.symbolKind}] line ${symbol.line}`;
3331
+ outputParts.push(prefix);
3332
+ outputParts.push(lines[lineIndex]);
3333
+ }
3334
+ }
3335
+ return outputParts.join("\n");
3336
+ }
2347
3337
  resolveClassFilePath(artifactId, className) {
2348
3338
  const normalizedClassName = className.trim();
2349
3339
  const classPath = classNameToClassPath(normalizedClassName);
@@ -2619,8 +3609,9 @@ export class SourceService {
2619
3609
  };
2620
3610
  }
2621
3611
  async remapSignatureMembers(members, kind, version, mapping, sourcePriority, warnings) {
3612
+ const failedNames = new Set();
2622
3613
  if (mapping === "official") {
2623
- return members;
3614
+ return { members, failedNames };
2624
3615
  }
2625
3616
  // Build deduplicated lookup tables for member names and owner FQNs
2626
3617
  const memberKeyToRemapped = new Map();
@@ -2634,60 +3625,83 @@ export class SourceService {
2634
3625
  ownerToRemapped.set(member.ownerFqn, member.ownerFqn); // default = official FQN
2635
3626
  }
2636
3627
  }
2637
- // Remap unique member names
2638
- const memberEntries = [...memberKeyToRemapped.entries()];
2639
- await Promise.all(memberEntries.map(async ([key, _officialName]) => {
2640
- const [ownerFqn, name, descriptor] = key.split("\0");
3628
+ // Phase 1: Remap owner FQNs first (needed for member disambiguation)
3629
+ const ownerEntries = [...ownerToRemapped.entries()];
3630
+ await Promise.all(ownerEntries.map(async ([officialFqn]) => {
2641
3631
  try {
2642
3632
  const mapped = await this.mappingService.findMapping({
2643
3633
  version,
2644
- kind,
2645
- name,
2646
- owner: ownerFqn,
2647
- descriptor: kind === "method" ? descriptor : undefined,
3634
+ kind: "class",
3635
+ name: officialFqn,
2648
3636
  sourceMapping: "official",
2649
3637
  targetMapping: mapping,
2650
3638
  sourcePriority
2651
3639
  });
2652
3640
  if (mapped.resolved && mapped.resolvedSymbol) {
2653
- memberKeyToRemapped.set(key, mapped.resolvedSymbol.name);
2654
- }
2655
- else {
2656
- warnings.push(`Could not remap ${kind} "${name}" to ${mapping}.`);
3641
+ ownerToRemapped.set(officialFqn, mapped.resolvedSymbol.name);
2657
3642
  }
2658
3643
  }
2659
3644
  catch {
2660
- warnings.push(`Remap failed for ${kind} "${name}".`);
3645
+ // keep official FQN as fallback
2661
3646
  }
2662
3647
  }));
2663
- // Remap unique owner FQNs
2664
- const ownerEntries = [...ownerToRemapped.entries()];
2665
- await Promise.all(ownerEntries.map(async ([officialFqn]) => {
3648
+ // Phase 2: Remap member names using resolved owners for disambiguation
3649
+ const memberEntries = [...memberKeyToRemapped.entries()];
3650
+ await Promise.all(memberEntries.map(async ([key, _officialName]) => {
3651
+ const [ownerFqn, name, descriptor] = key.split("\0");
2666
3652
  try {
3653
+ const targetOwner = ownerToRemapped.get(ownerFqn) ?? ownerFqn;
2667
3654
  const mapped = await this.mappingService.findMapping({
2668
3655
  version,
2669
- kind: "class",
2670
- name: officialFqn,
3656
+ kind,
3657
+ name,
3658
+ owner: ownerFqn,
3659
+ descriptor: kind === "method" ? descriptor : undefined,
2671
3660
  sourceMapping: "official",
2672
3661
  targetMapping: mapping,
2673
- sourcePriority
3662
+ sourcePriority,
3663
+ disambiguation: { ownerHint: targetOwner }
2674
3664
  });
2675
3665
  if (mapped.resolved && mapped.resolvedSymbol) {
2676
- ownerToRemapped.set(officialFqn, mapped.resolvedSymbol.name);
3666
+ memberKeyToRemapped.set(key, mapped.resolvedSymbol.name);
3667
+ }
3668
+ else if (mapped.status === "ambiguous" && mapped.candidates && mapped.candidates.length > 0) {
3669
+ // Disambiguate: filter by target owner and pick the best candidate
3670
+ const ownerMatched = mapped.candidates.filter((c) => c.owner === targetOwner);
3671
+ const best = ownerMatched.length > 0 ? ownerMatched : mapped.candidates;
3672
+ if (best.length > 0) {
3673
+ memberKeyToRemapped.set(key, best[0].name);
3674
+ // Only mark as failed if the best candidate is not a high-confidence match
3675
+ if (best[0].confidence < 0.9) {
3676
+ failedNames.add(name);
3677
+ }
3678
+ }
3679
+ else {
3680
+ warnings.push(`Could not remap ${kind} "${name}" to ${mapping}.`);
3681
+ failedNames.add(name);
3682
+ }
3683
+ }
3684
+ else {
3685
+ warnings.push(`Could not remap ${kind} "${name}" to ${mapping}.`);
3686
+ failedNames.add(name);
2677
3687
  }
2678
3688
  }
2679
3689
  catch {
2680
- // keep official FQN as fallback
3690
+ warnings.push(`Remap failed for ${kind} "${name}".`);
3691
+ failedNames.add(name);
2681
3692
  }
2682
3693
  }));
2683
- return members.map((member) => {
2684
- const memberKey = `${member.ownerFqn}\0${member.name}\0${member.jvmDescriptor}`;
2685
- return {
2686
- ...member,
2687
- name: memberKeyToRemapped.get(memberKey) ?? member.name,
2688
- ownerFqn: ownerToRemapped.get(member.ownerFqn) ?? member.ownerFqn
2689
- };
2690
- });
3694
+ return {
3695
+ members: members.map((member) => {
3696
+ const memberKey = `${member.ownerFqn}\0${member.name}\0${member.jvmDescriptor}`;
3697
+ return {
3698
+ ...member,
3699
+ name: memberKeyToRemapped.get(memberKey) ?? member.name,
3700
+ ownerFqn: ownerToRemapped.get(member.ownerFqn) ?? member.ownerFqn
3701
+ };
3702
+ }),
3703
+ failedNames
3704
+ };
2691
3705
  }
2692
3706
  fallbackArtifactSignature(artifactId) {
2693
3707
  return createHash("sha256").update(artifactId).digest("hex");
@@ -2798,6 +3812,25 @@ export class SourceService {
2798
3812
  contentHash: createHash("sha256").update(entry.content).digest("hex")
2799
3813
  }));
2800
3814
  }
3815
+ catch (caughtError) {
3816
+ if (isAppError(caughtError) && caughtError.code === ERROR_CODES.DECOMPILER_FAILED) {
3817
+ throw createError({
3818
+ code: ERROR_CODES.DECOMPILER_FAILED,
3819
+ message: caughtError.message,
3820
+ details: {
3821
+ ...(caughtError.details ?? {}),
3822
+ artifactId: resolved.artifactId,
3823
+ binaryJarPath: resolved.binaryJarPath,
3824
+ producedJavaCount: typeof caughtError.details?.producedJavaCount === "number"
3825
+ ? caughtError.details.producedJavaCount
3826
+ : 0,
3827
+ nextAction: "Verify Java runtime and Vineflower availability, then retry. If available, prefer source-backed artifacts.",
3828
+ recommendedCommand: "echo $MCP_VINEFLOWER_JAR_PATH"
3829
+ }
3830
+ });
3831
+ }
3832
+ throw caughtError;
3833
+ }
2801
3834
  finally {
2802
3835
  this.metrics.recordDuration("decompile_duration_ms", Date.now() - decompileStartedAt);
2803
3836
  }
@@ -2806,7 +3839,11 @@ export class SourceService {
2806
3839
  throw createError({
2807
3840
  code: ERROR_CODES.SOURCE_NOT_FOUND,
2808
3841
  message: "No source artifact available.",
2809
- details: { artifactId: resolved.artifactId }
3842
+ details: {
3843
+ artifactId: resolved.artifactId,
3844
+ nextAction: "Use list-artifact-files to inspect the artifact's contents.",
3845
+ suggestedCall: { tool: "list-artifact-files", params: { artifactId: resolved.artifactId } }
3846
+ }
2810
3847
  });
2811
3848
  }
2812
3849
  const symbols = [];
@@ -2840,7 +3877,11 @@ export class SourceService {
2840
3877
  throw createError({
2841
3878
  code: ERROR_CODES.SOURCE_NOT_FOUND,
2842
3879
  message: "Artifact not found. Resolve context first.",
2843
- details: { artifactId }
3880
+ details: {
3881
+ artifactId,
3882
+ nextAction: "Use resolve-artifact to resolve a source artifact first.",
3883
+ suggestedCall: { tool: "resolve-artifact", params: { targetKind: "version", targetValue: "latest" } }
3884
+ }
2844
3885
  });
2845
3886
  }
2846
3887
  return artifact;