@adhisang/minecraft-modding-mcp 1.1.0 → 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) {
@@ -258,11 +337,33 @@ function canUseIndexedSearchPath(indexedSearchEnabled, intent, match, _scope) {
258
337
  return true;
259
338
  }
260
339
  function buildGlobRegex(pattern) {
261
- const escaped = pattern
262
- .replace(/[-/\\^$+?.()|[\]{}]/g, "\\$&")
263
- .replace(/\\\*/g, ".*")
264
- .replace(/\\\?/g, ".");
265
- return new RegExp(`^${escaped}$`);
340
+ const REGEX_META = /[-/\\^$+.()|[\]{}]/;
341
+ let result = "";
342
+ let i = 0;
343
+ while (i < pattern.length) {
344
+ const ch = pattern[i];
345
+ if (ch === "*" && pattern[i + 1] === "*") {
346
+ result += ".*";
347
+ i += 2;
348
+ if (pattern[i] === "/") {
349
+ result += "(?:/)?";
350
+ i += 1;
351
+ }
352
+ }
353
+ else if (ch === "*") {
354
+ result += "[^/]*";
355
+ i += 1;
356
+ }
357
+ else if (ch === "?") {
358
+ result += "[^/]";
359
+ i += 1;
360
+ }
361
+ else {
362
+ result += REGEX_META.test(ch) ? `\\${ch}` : ch;
363
+ i += 1;
364
+ }
365
+ }
366
+ return new RegExp(`^${result}$`);
266
367
  }
267
368
  function globToSqlLike(pattern) {
268
369
  let result = "";
@@ -283,6 +384,27 @@ function globToSqlLike(pattern) {
283
384
  }
284
385
  return result;
285
386
  }
387
+ function isPackageCompatible(filePath, classPath) {
388
+ const lastSlash = classPath.lastIndexOf("/");
389
+ if (lastSlash < 0)
390
+ return true;
391
+ const expectedPrefix = classPath.slice(0, lastSlash + 1);
392
+ return filePath.startsWith(expectedPrefix);
393
+ }
394
+ function classNameToClassPath(className) {
395
+ const normalized = normalizePathStyle(className.trim()).replace(/\//g, ".");
396
+ const segments = normalized.split(".").filter((segment) => segment.length > 0);
397
+ if (segments.length === 0) {
398
+ return "";
399
+ }
400
+ const firstTypeSegment = segments.findIndex((segment) => /^[A-Z_$]/.test(segment));
401
+ if (firstTypeSegment < 0) {
402
+ return segments.join("/");
403
+ }
404
+ const packagePath = segments.slice(0, firstTypeSegment).join("/");
405
+ const typePath = segments.slice(firstTypeSegment).join("$");
406
+ return packagePath ? `${packagePath}/${typePath}` : typePath;
407
+ }
286
408
  function checkPackagePrefix(filePath, packagePrefix) {
287
409
  if (!packagePrefix) {
288
410
  return true;
@@ -304,6 +426,7 @@ function buildSearchCursorContext(input) {
304
426
  query: input.query,
305
427
  intent: input.intent,
306
428
  match: input.match,
429
+ queryMode: input.queryMode,
307
430
  includeDefinition: input.includeDefinition,
308
431
  packagePrefix: input.scope?.packagePrefix ?? "",
309
432
  fileGlob: input.scope?.fileGlob ?? "",
@@ -479,11 +602,95 @@ export class SourceService {
479
602
  this.symbolsRepo = new SymbolsRepo(this.db);
480
603
  this.refreshCacheMetrics();
481
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
+ }
482
680
  async resolveArtifact(input) {
483
681
  const kind = input.target.kind;
484
- const value = input.target.value?.trim();
682
+ let value = input.target.value?.trim();
485
683
  const mapping = normalizeMapping(input.mapping);
684
+ const scope = input.scope;
486
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
+ }
487
694
  if (!value) {
488
695
  throw createError({
489
696
  code: ERROR_CODES.INVALID_INPUT,
@@ -509,6 +716,7 @@ export class SourceService {
509
716
  try {
510
717
  let resolvedTarget = { kind, value };
511
718
  let resolvedVersion;
719
+ let versionSourceDiscovery;
512
720
  if (kind === "version") {
513
721
  const versionJar = await this.versionService.resolveVersionJar(value);
514
722
  resolvedVersion = versionJar.version;
@@ -534,6 +742,19 @@ export class SourceService {
534
742
  warnings.push(`Version ${resolvedVersion} is unobfuscated; ${mapping} mappings are not applicable. Using official names.`);
535
743
  effectiveMapping = "official";
536
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
+ }
537
758
  const resolved = await resolveSourceTargetInternal(resolvedTarget, {
538
759
  // mojang requires source-backed artifact guarantee; force resolution to consider decompile candidate
539
760
  // and reject later if mapping cannot be applied.
@@ -551,11 +772,59 @@ export class SourceService {
551
772
  }
552
773
  }, this.config);
553
774
  resolved.version = resolvedVersion;
554
- const mappingDecision = applyMappingPipeline({
555
- requestedMapping: effectiveMapping,
556
- target: { kind, value },
557
- resolved
558
- });
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
+ }
559
828
  const additionalTransformChain = [];
560
829
  if (effectiveMapping === "intermediary" || effectiveMapping === "yarn") {
561
830
  if (!resolved.version) {
@@ -565,7 +834,8 @@ export class SourceService {
565
834
  details: {
566
835
  mapping: effectiveMapping,
567
836
  target: { kind, value },
568
- 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 } : {}) } }
569
839
  }
570
840
  });
571
841
  }
@@ -588,8 +858,46 @@ export class SourceService {
588
858
  resolved.requestedMapping = effectiveMapping;
589
859
  resolved.mappingApplied = mappingDecision.mappingApplied;
590
860
  resolved.provenance = provenance;
591
- 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)];
592
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
+ }
593
901
  return {
594
902
  artifactId: resolved.artifactId,
595
903
  origin: resolved.origin,
@@ -602,9 +910,10 @@ export class SourceService {
602
910
  requestedMapping: effectiveMapping,
603
911
  mappingApplied: mappingDecision.mappingApplied,
604
912
  provenance,
605
- qualityFlags: mappingDecision.qualityFlags,
913
+ qualityFlags: resolved.qualityFlags,
606
914
  repoUrl: resolved.repoUrl,
607
- warnings
915
+ warnings,
916
+ sampleEntries
608
917
  };
609
918
  }
610
919
  catch (caughtError) {
@@ -658,11 +967,13 @@ export class SourceService {
658
967
  const snippetWindow = buildSnippetWindow(input.include?.snippetLines);
659
968
  const regexPattern = match === "regex" ? compileRegex(query) : undefined;
660
969
  const scope = input.scope;
970
+ const queryMode = input.queryMode ?? "auto";
661
971
  const cursorContext = buildSearchCursorContext({
662
972
  artifactId: artifact.artifactId,
663
973
  query,
664
974
  intent,
665
975
  match,
976
+ queryMode,
666
977
  scope,
667
978
  includeDefinition
668
979
  });
@@ -677,6 +988,8 @@ export class SourceService {
677
988
  const recordHit = (hit) => {
678
989
  accumulator.add(hit);
679
990
  };
991
+ const hasSeparators = /[._$]/.test(query);
992
+ const tokenOnlyTextIntent = intent === "text" && queryMode === "token";
680
993
  if (intent === "symbol") {
681
994
  this.searchSymbolIntent(artifact.artifactId, query, match, scope, snippetWindow, regexPattern, recordHit);
682
995
  // WS4: Use repo-level COUNT for symbol totalApprox when not regex
@@ -691,14 +1004,21 @@ export class SourceService {
691
1004
  accumulator.setTotalApproxOverride(approxCount);
692
1005
  }
693
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
+ }
694
1012
  else if (!indexedSearchEnabled) {
695
1013
  this.metrics.recordIndexedDisabled();
696
- this.metrics.recordSearchFallback();
697
- if (intent === "path") {
698
- this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
699
- }
700
- else {
701
- 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
+ }
702
1022
  }
703
1023
  }
704
1024
  else if (canUseIndexedSearchPath(indexedSearchEnabled, intent, match, scope)) {
@@ -714,6 +1034,10 @@ export class SourceService {
714
1034
  // WS4: Use repo-level COUNT for totalApprox instead of accumulator count
715
1035
  const approxCount = this.filesRepo.countTextCandidates(artifact.artifactId, query);
716
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
+ }
717
1041
  }
718
1042
  this.metrics.recordSearchIndexedHit();
719
1043
  }
@@ -725,6 +1049,20 @@ export class SourceService {
725
1049
  match,
726
1050
  reason: caughtError instanceof Error ? caughtError.message : String(caughtError)
727
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();
728
1066
  if (intent === "path") {
729
1067
  this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
730
1068
  }
@@ -733,15 +1071,6 @@ export class SourceService {
733
1071
  }
734
1072
  }
735
1073
  }
736
- else {
737
- this.metrics.recordSearchFallback();
738
- if (intent === "path") {
739
- this.searchPathIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
740
- }
741
- else {
742
- this.searchTextIntent(artifact.artifactId, query, match, scope, includeDefinition, snippetWindow, regexPattern, recordHit);
743
- }
744
- }
745
1074
  this.metrics.recordSearchIntentDuration(intent, Date.now() - intentStartedAt);
746
1075
  const finalizedHits = accumulator.finalize();
747
1076
  const page = finalizedHits.page;
@@ -765,11 +1094,14 @@ export class SourceService {
765
1094
  this.metrics.recordOneHopExpansion(relations.length);
766
1095
  }
767
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;
768
1100
  return {
769
1101
  hits: page,
770
1102
  relations: relations && relations.length > 0 ? relations : undefined,
771
1103
  nextCursor,
772
- totalApprox: finalizedHits.totalApprox,
1104
+ totalApprox,
773
1105
  mappingApplied: artifact.mappingApplied ?? "official"
774
1106
  };
775
1107
  }
@@ -792,9 +1124,7 @@ export class SourceService {
792
1124
  const maxBytes = clampLimit(input.maxBytes, this.config.maxContentBytes, Number.MAX_SAFE_INTEGER);
793
1125
  const fullBytes = Buffer.byteLength(row.content, "utf8");
794
1126
  const truncated = fullBytes > maxBytes;
795
- const content = truncated
796
- ? Buffer.from(row.content, "utf8").slice(0, maxBytes).toString("utf8")
797
- : row.content;
1127
+ const content = truncated ? truncateUtf8ToMaxBytes(row.content, maxBytes) : row.content;
798
1128
  if (truncated) {
799
1129
  log("warn", "source.get_file.truncated", {
800
1130
  artifactId: input.artifactId,
@@ -1090,7 +1420,11 @@ export class SourceService {
1090
1420
  throw createError({
1091
1421
  code: ERROR_CODES.VERSION_NOT_FOUND,
1092
1422
  message: "No Minecraft versions were returned by manifest.",
1093
- details: { includeSnapshots }
1423
+ details: {
1424
+ includeSnapshots,
1425
+ nextAction: "Use list-versions to see available Minecraft versions.",
1426
+ suggestedCall: { tool: "list-versions", params: {} }
1427
+ }
1094
1428
  });
1095
1429
  }
1096
1430
  const chronological = [...manifestOrder].reverse();
@@ -1102,14 +1436,22 @@ export class SourceService {
1102
1436
  throw createError({
1103
1437
  code: ERROR_CODES.VERSION_NOT_FOUND,
1104
1438
  message: `fromVersion "${requestedFrom}" was not found in manifest.`,
1105
- 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
+ }
1106
1444
  });
1107
1445
  }
1108
1446
  if (toIndex < 0) {
1109
1447
  throw createError({
1110
1448
  code: ERROR_CODES.VERSION_NOT_FOUND,
1111
1449
  message: `toVersion "${requestedTo}" was not found in manifest.`,
1112
- 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
+ }
1113
1455
  });
1114
1456
  }
1115
1457
  if (fromIndex > toIndex) {
@@ -1248,7 +1590,11 @@ export class SourceService {
1248
1590
  if (manifestOrder.length === 0) {
1249
1591
  throw createError({
1250
1592
  code: ERROR_CODES.VERSION_NOT_FOUND,
1251
- 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
+ }
1252
1598
  });
1253
1599
  }
1254
1600
  const chronological = [...manifestOrder].reverse();
@@ -1258,14 +1604,22 @@ export class SourceService {
1258
1604
  throw createError({
1259
1605
  code: ERROR_CODES.VERSION_NOT_FOUND,
1260
1606
  message: `fromVersion "${fromVersion}" was not found in manifest.`,
1261
- details: { fromVersion }
1607
+ details: {
1608
+ fromVersion,
1609
+ nextAction: "Use list-versions to see available Minecraft versions.",
1610
+ suggestedCall: { tool: "list-versions", params: {} }
1611
+ }
1262
1612
  });
1263
1613
  }
1264
1614
  if (toIndex < 0) {
1265
1615
  throw createError({
1266
1616
  code: ERROR_CODES.VERSION_NOT_FOUND,
1267
1617
  message: `toVersion "${toVersion}" was not found in manifest.`,
1268
- details: { toVersion }
1618
+ details: {
1619
+ toVersion,
1620
+ nextAction: "Use list-versions to see available Minecraft versions.",
1621
+ suggestedCall: { tool: "list-versions", params: {} }
1622
+ }
1269
1623
  });
1270
1624
  }
1271
1625
  if (fromIndex > toIndex) {
@@ -1388,18 +1742,18 @@ export class SourceService {
1388
1742
  : diffMembersByKey(fromMembers.fields, toMembers.fields, (member) => member.name, true);
1389
1743
  // Remap diff delta members for non-official mappings
1390
1744
  const remapDelta = async (delta, kind) => {
1391
- const [remappedAdded, remappedRemoved] = await Promise.all([
1745
+ const [addedResult, removedResult] = await Promise.all([
1392
1746
  this.remapSignatureMembers(delta.added, kind, toVersion, mapping, input.sourcePriority, warnings),
1393
1747
  this.remapSignatureMembers(delta.removed, kind, fromVersion, mapping, input.sourcePriority, warnings)
1394
1748
  ]);
1395
1749
  const remappedModified = await Promise.all(delta.modified.map(async (change) => {
1396
- const [fromArr, toArr] = await Promise.all([
1750
+ const [fromResult, toResult] = await Promise.all([
1397
1751
  this.remapSignatureMembers([change.from], kind, fromVersion, mapping, input.sourcePriority, warnings),
1398
1752
  this.remapSignatureMembers([change.to], kind, toVersion, mapping, input.sourcePriority, warnings)
1399
1753
  ]);
1400
- return { ...change, from: fromArr[0], to: toArr[0] };
1754
+ return { ...change, from: fromResult.members[0], to: toResult.members[0] };
1401
1755
  }));
1402
- return { added: remappedAdded, removed: remappedRemoved, modified: remappedModified };
1756
+ return { added: addedResult.members, removed: removedResult.members, modified: remappedModified };
1403
1757
  };
1404
1758
  const [remappedConstructors, remappedMethods, remappedFields] = await Promise.all([
1405
1759
  remapDelta(constructors, "method"),
@@ -1447,6 +1801,78 @@ export class SourceService {
1447
1801
  warnings
1448
1802
  };
1449
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
+ }
1450
1876
  async getClassSource(input) {
1451
1877
  const className = input.className.trim();
1452
1878
  if (!className) {
@@ -1455,9 +1881,16 @@ export class SourceService {
1455
1881
  message: "className must be non-empty."
1456
1882
  });
1457
1883
  }
1884
+ const mode = input.mode ?? "metadata";
1458
1885
  const startLine = normalizeStrictPositiveInt(input.startLine, "startLine");
1459
1886
  const endLine = normalizeStrictPositiveInt(input.endLine, "endLine");
1460
- 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
+ }
1461
1894
  if (startLine != null && endLine != null && startLine > endLine) {
1462
1895
  throw createError({
1463
1896
  code: ERROR_CODES.INVALID_LINE_RANGE,
@@ -1497,7 +1930,11 @@ export class SourceService {
1497
1930
  target: input.target,
1498
1931
  mapping: input.mapping,
1499
1932
  sourcePriority: input.sourcePriority,
1500
- allowDecompile: input.allowDecompile
1933
+ allowDecompile: input.allowDecompile,
1934
+ projectPath: input.projectPath,
1935
+ scope: input.scope,
1936
+ preferProjectVersion: input.preferProjectVersion,
1937
+ strictVersion: input.strictVersion
1501
1938
  });
1502
1939
  artifactId = resolved.artifactId;
1503
1940
  origin = resolved.origin;
@@ -1517,41 +1954,100 @@ export class SourceService {
1517
1954
  }
1518
1955
  const filePath = this.resolveClassFilePath(artifactId, className);
1519
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
+ }
1520
1970
  throw createError({
1521
1971
  code: ERROR_CODES.CLASS_NOT_FOUND,
1522
1972
  message: `Source for class "${className}" was not found.`,
1523
1973
  details: {
1524
1974
  artifactId,
1525
- 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 } }
1526
1982
  }
1527
1983
  });
1528
1984
  }
1529
1985
  const row = this.filesRepo.getFileContent(artifactId, filePath);
1530
1986
  if (!row) {
1987
+ const simpleName = className.split(/[.$]/).at(-1) ?? className;
1531
1988
  throw createError({
1532
1989
  code: ERROR_CODES.CLASS_NOT_FOUND,
1533
1990
  message: `Source for class "${className}" was not found.`,
1534
1991
  details: {
1535
1992
  artifactId,
1536
1993
  className,
1537
- 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 } }
1538
2000
  }
1539
2001
  });
1540
2002
  }
1541
2003
  const lines = row.content.split(/\r?\n/);
1542
2004
  const totalLines = lines.length;
1543
- const requestedStart = startLine ?? 1;
1544
- const requestedEnd = endLine ?? totalLines;
1545
- const normalizedStart = Math.min(Math.max(1, requestedStart), Math.max(totalLines, 1));
1546
- const normalizedEnd = Math.min(Math.max(normalizedStart, requestedEnd), Math.max(totalLines, 1));
1547
- let selectedLines = lines.slice(normalizedStart - 1, normalizedEnd);
1548
- const clippedByRange = normalizedStart !== requestedStart || normalizedEnd !== requestedEnd;
1549
- let clippedByMax = false;
1550
- if (maxLines != null && selectedLines.length > maxLines) {
1551
- selectedLines = selectedLines.slice(0, maxLines);
1552
- clippedByMax = true;
1553
- }
1554
- 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
+ }
1555
2051
  const normalizedProvenance = provenance ??
1556
2052
  this.buildFallbackProvenance({
1557
2053
  artifactId,
@@ -1561,19 +2057,22 @@ export class SourceService {
1561
2057
  });
1562
2058
  return {
1563
2059
  className,
1564
- sourceText: selectedLines.join("\n"),
2060
+ mode,
2061
+ sourceText,
1565
2062
  totalLines,
1566
2063
  returnedRange: {
1567
- start: normalizedStart,
2064
+ start: returnedStart,
1568
2065
  end: returnedEnd
1569
2066
  },
1570
- truncated: clippedByRange || clippedByMax,
2067
+ truncated,
2068
+ ...(charsTruncated ? { charsTruncated } : {}),
1571
2069
  origin,
1572
2070
  artifactId,
1573
2071
  requestedMapping,
1574
2072
  mappingApplied,
1575
2073
  provenance: normalizedProvenance,
1576
2074
  qualityFlags,
2075
+ ...(resolvedOutputFile ? { outputFile: resolvedOutputFile } : {}),
1577
2076
  warnings
1578
2077
  };
1579
2078
  }
@@ -1625,7 +2124,11 @@ export class SourceService {
1625
2124
  target: input.target,
1626
2125
  mapping: requestedMapping,
1627
2126
  sourcePriority: input.sourcePriority,
1628
- allowDecompile: input.allowDecompile
2127
+ allowDecompile: input.allowDecompile,
2128
+ projectPath: input.projectPath,
2129
+ scope: input.scope,
2130
+ preferProjectVersion: input.preferProjectVersion,
2131
+ strictVersion: input.strictVersion
1629
2132
  });
1630
2133
  artifactId = resolved.artifactId;
1631
2134
  origin = resolved.origin;
@@ -1651,7 +2154,8 @@ export class SourceService {
1651
2154
  message: `Non-official mapping "${requestedMapping}" requires a version, but none was resolved.`,
1652
2155
  details: {
1653
2156
  mapping: requestedMapping,
1654
- 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" } }
1655
2159
  }
1656
2160
  });
1657
2161
  }
@@ -1679,13 +2183,13 @@ export class SourceService {
1679
2183
  });
1680
2184
  warnings.push(...signature.warnings);
1681
2185
  let remappedConstructors = version != null
1682
- ? await this.remapSignatureMembers(signature.constructors, "method", version, requestedMapping, input.sourcePriority, warnings)
2186
+ ? (await this.remapSignatureMembers(signature.constructors, "method", version, requestedMapping, input.sourcePriority, warnings)).members
1683
2187
  : signature.constructors;
1684
2188
  let remappedFields = version != null
1685
- ? await this.remapSignatureMembers(signature.fields, "field", version, requestedMapping, input.sourcePriority, warnings)
2189
+ ? (await this.remapSignatureMembers(signature.fields, "field", version, requestedMapping, input.sourcePriority, warnings)).members
1686
2190
  : signature.fields;
1687
2191
  let remappedMethods = version != null
1688
- ? await this.remapSignatureMembers(signature.methods, "method", version, requestedMapping, input.sourcePriority, warnings)
2192
+ ? (await this.remapSignatureMembers(signature.methods, "method", version, requestedMapping, input.sourcePriority, warnings)).members
1689
2193
  : signature.methods;
1690
2194
  // Apply memberPattern post-remap for non-official mappings
1691
2195
  if (requestedMapping !== "official" && memberPattern) {
@@ -1744,40 +2248,272 @@ export class SourceService {
1744
2248
  };
1745
2249
  }
1746
2250
  async validateMixin(input) {
1747
- 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();
1748
2337
  if (!version) {
1749
2338
  throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "version must be non-empty." });
1750
2339
  }
1751
- 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
+ }
1752
2361
  if (!source.trim()) {
1753
2362
  throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "source must be non-empty." });
1754
2363
  }
1755
2364
  const warnings = [];
1756
- const requestedMapping = normalizeMapping(input.mapping);
1757
- 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
+ }
1758
2464
  const parsed = parseMixinSource(source);
1759
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;
1760
2471
  for (const target of parsed.targets) {
1761
- 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;
1762
2493
  if (requestedMapping !== "official") {
1763
2494
  try {
1764
2495
  const mapped = await this.mappingService.findMapping({
1765
2496
  version,
1766
2497
  kind: "class",
1767
- name: target.className,
2498
+ name: resolvedClassName,
1768
2499
  sourceMapping: requestedMapping,
1769
2500
  targetMapping: "official",
1770
2501
  sourcePriority: input.sourcePriority
1771
2502
  });
1772
2503
  if (mapped.resolved && mapped.resolvedSymbol) {
1773
2504
  officialName = mapped.resolvedSymbol.name;
2505
+ resolutionTrace?.push({ target: target.className, step: "mapping", input: resolvedClassName, output: officialName, success: true });
1774
2506
  }
1775
2507
  else {
1776
- 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" });
1777
2511
  }
1778
2512
  }
1779
- catch {
1780
- 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) });
1781
2517
  }
1782
2518
  }
1783
2519
  try {
@@ -1787,18 +2523,288 @@ export class SourceService {
1787
2523
  access: "all"
1788
2524
  });
1789
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
+ }
1790
2563
  targetMembers.set(target.className, {
1791
2564
  className: target.className,
1792
- constructors: sig.constructors,
1793
- methods: sig.methods,
1794
- fields: sig.fields
2565
+ constructors,
2566
+ methods,
2567
+ fields
1795
2568
  });
1796
2569
  }
1797
- catch {
1798
- 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}`);
1799
2624
  }
1800
2625
  }
1801
- return validateParsedMixin(parsed, targetMembers, warnings);
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
+ }
2772
+ }
2773
+ }
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
+ };
1802
2808
  }
1803
2809
  async validateAccessWidener(input) {
1804
2810
  const version = input.version.trim();
@@ -2301,9 +3307,39 @@ export class SourceService {
2301
3307
  // Contains matches need more candidates
2302
3308
  return base;
2303
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
+ }
2304
3337
  resolveClassFilePath(artifactId, className) {
2305
3338
  const normalizedClassName = className.trim();
2306
- const classPath = normalizePathStyle(normalizedClassName.replace(/\./g, "/"));
3339
+ const classPath = classNameToClassPath(normalizedClassName);
3340
+ if (!classPath) {
3341
+ return undefined;
3342
+ }
2307
3343
  const candidates = new Set([`${classPath}.java`]);
2308
3344
  const innerIndex = classPath.indexOf("$");
2309
3345
  if (innerIndex > 0) {
@@ -2320,10 +3356,14 @@ export class SourceService {
2320
3356
  return undefined;
2321
3357
  }
2322
3358
  const classPathBySymbol = this.symbolsRepo.findBestClassFilePath(artifactId, normalizedClassName, simpleName);
2323
- if (classPathBySymbol) {
3359
+ if (classPathBySymbol && isPackageCompatible(classPathBySymbol, classPath)) {
2324
3360
  return classPathBySymbol;
2325
3361
  }
2326
- return this.filesRepo.findFirstFilePathByName(artifactId, `${simpleName}.java`);
3362
+ const byName = this.filesRepo.findFirstFilePathByName(artifactId, `${simpleName}.java`);
3363
+ if (byName && isPackageCompatible(byName, classPath)) {
3364
+ return byName;
3365
+ }
3366
+ return undefined;
2327
3367
  }
2328
3368
  findNearestSymbolForLine(artifactId, filePath, line, symbolKind) {
2329
3369
  const symbols = this.symbolsRepo
@@ -2569,8 +3609,9 @@ export class SourceService {
2569
3609
  };
2570
3610
  }
2571
3611
  async remapSignatureMembers(members, kind, version, mapping, sourcePriority, warnings) {
3612
+ const failedNames = new Set();
2572
3613
  if (mapping === "official") {
2573
- return members;
3614
+ return { members, failedNames };
2574
3615
  }
2575
3616
  // Build deduplicated lookup tables for member names and owner FQNs
2576
3617
  const memberKeyToRemapped = new Map();
@@ -2584,60 +3625,83 @@ export class SourceService {
2584
3625
  ownerToRemapped.set(member.ownerFqn, member.ownerFqn); // default = official FQN
2585
3626
  }
2586
3627
  }
2587
- // Remap unique member names
2588
- const memberEntries = [...memberKeyToRemapped.entries()];
2589
- await Promise.all(memberEntries.map(async ([key, _officialName]) => {
2590
- 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]) => {
2591
3631
  try {
2592
3632
  const mapped = await this.mappingService.findMapping({
2593
3633
  version,
2594
- kind,
2595
- name,
2596
- owner: ownerFqn,
2597
- descriptor: kind === "method" ? descriptor : undefined,
3634
+ kind: "class",
3635
+ name: officialFqn,
2598
3636
  sourceMapping: "official",
2599
3637
  targetMapping: mapping,
2600
3638
  sourcePriority
2601
3639
  });
2602
3640
  if (mapped.resolved && mapped.resolvedSymbol) {
2603
- memberKeyToRemapped.set(key, mapped.resolvedSymbol.name);
2604
- }
2605
- else {
2606
- warnings.push(`Could not remap ${kind} "${name}" to ${mapping}.`);
3641
+ ownerToRemapped.set(officialFqn, mapped.resolvedSymbol.name);
2607
3642
  }
2608
3643
  }
2609
3644
  catch {
2610
- warnings.push(`Remap failed for ${kind} "${name}".`);
3645
+ // keep official FQN as fallback
2611
3646
  }
2612
3647
  }));
2613
- // Remap unique owner FQNs
2614
- const ownerEntries = [...ownerToRemapped.entries()];
2615
- 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");
2616
3652
  try {
3653
+ const targetOwner = ownerToRemapped.get(ownerFqn) ?? ownerFqn;
2617
3654
  const mapped = await this.mappingService.findMapping({
2618
3655
  version,
2619
- kind: "class",
2620
- name: officialFqn,
3656
+ kind,
3657
+ name,
3658
+ owner: ownerFqn,
3659
+ descriptor: kind === "method" ? descriptor : undefined,
2621
3660
  sourceMapping: "official",
2622
3661
  targetMapping: mapping,
2623
- sourcePriority
3662
+ sourcePriority,
3663
+ disambiguation: { ownerHint: targetOwner }
2624
3664
  });
2625
3665
  if (mapped.resolved && mapped.resolvedSymbol) {
2626
- 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);
2627
3687
  }
2628
3688
  }
2629
3689
  catch {
2630
- // keep official FQN as fallback
3690
+ warnings.push(`Remap failed for ${kind} "${name}".`);
3691
+ failedNames.add(name);
2631
3692
  }
2632
3693
  }));
2633
- return members.map((member) => {
2634
- const memberKey = `${member.ownerFqn}\0${member.name}\0${member.jvmDescriptor}`;
2635
- return {
2636
- ...member,
2637
- name: memberKeyToRemapped.get(memberKey) ?? member.name,
2638
- ownerFqn: ownerToRemapped.get(member.ownerFqn) ?? member.ownerFqn
2639
- };
2640
- });
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
+ };
2641
3705
  }
2642
3706
  fallbackArtifactSignature(artifactId) {
2643
3707
  return createHash("sha256").update(artifactId).digest("hex");
@@ -2748,6 +3812,25 @@ export class SourceService {
2748
3812
  contentHash: createHash("sha256").update(entry.content).digest("hex")
2749
3813
  }));
2750
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
+ }
2751
3834
  finally {
2752
3835
  this.metrics.recordDuration("decompile_duration_ms", Date.now() - decompileStartedAt);
2753
3836
  }
@@ -2756,7 +3839,11 @@ export class SourceService {
2756
3839
  throw createError({
2757
3840
  code: ERROR_CODES.SOURCE_NOT_FOUND,
2758
3841
  message: "No source artifact available.",
2759
- 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
+ }
2760
3847
  });
2761
3848
  }
2762
3849
  const symbols = [];
@@ -2790,7 +3877,11 @@ export class SourceService {
2790
3877
  throw createError({
2791
3878
  code: ERROR_CODES.SOURCE_NOT_FOUND,
2792
3879
  message: "Artifact not found. Resolve context first.",
2793
- 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
+ }
2794
3885
  });
2795
3886
  }
2796
3887
  return artifact;