@adhisang/minecraft-modding-mcp 6.1.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +12 -4
  3. package/dist/cache-registry.d.ts +1 -1
  4. package/dist/cache-registry.js +3 -0
  5. package/dist/entry-tools/analyze-mod-service.d.ts +12 -6
  6. package/dist/entry-tools/analyze-mod-service.js +37 -3
  7. package/dist/entry-tools/analyze-symbol-service.d.ts +6 -4
  8. package/dist/entry-tools/analyze-symbol-service.js +37 -2
  9. package/dist/entry-tools/inspect-minecraft/internal.d.ts +7 -3
  10. package/dist/entry-tools/inspect-minecraft/internal.js +43 -15
  11. package/dist/entry-tools/inspect-minecraft-service.d.ts +12 -12
  12. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  13. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  14. package/dist/error-mapping.d.ts +13 -0
  15. package/dist/error-mapping.js +35 -2
  16. package/dist/errors.d.ts +2 -0
  17. package/dist/errors.js +2 -0
  18. package/dist/index.js +39 -20
  19. package/dist/mapping/internal-types.d.ts +7 -0
  20. package/dist/mapping/types.d.ts +18 -0
  21. package/dist/mapping-service.js +16 -3
  22. package/dist/minecraft-explorer-service.d.ts +4 -0
  23. package/dist/minecraft-explorer-service.js +156 -17
  24. package/dist/mod-analyzer.d.ts +7 -0
  25. package/dist/mod-analyzer.js +28 -7
  26. package/dist/source/artifact-resolver.d.ts +2 -0
  27. package/dist/source/artifact-resolver.js +24 -1
  28. package/dist/source/class-source/members-builder.d.ts +4 -0
  29. package/dist/source/class-source/members-builder.js +3 -1
  30. package/dist/source/class-source.d.ts +3 -1
  31. package/dist/source/class-source.js +192 -19
  32. package/dist/source/did-you-mean.d.ts +14 -0
  33. package/dist/source/did-you-mean.js +79 -0
  34. package/dist/source/file-access.js +159 -3
  35. package/dist/source/indexer.js +72 -2
  36. package/dist/source/lifecycle/runtime-check.js +9 -5
  37. package/dist/source/nested-jars.d.ts +78 -0
  38. package/dist/source/nested-jars.js +267 -0
  39. package/dist/source/workspace-target.js +5 -2
  40. package/dist/source-jar-reader.d.ts +16 -0
  41. package/dist/source-jar-reader.js +82 -0
  42. package/dist/source-service.d.ts +37 -0
  43. package/dist/source-service.js +52 -6
  44. package/dist/stage-emitter.js +24 -8
  45. package/dist/stdio-supervisor.d.ts +92 -9
  46. package/dist/stdio-supervisor.js +915 -103
  47. package/dist/tool-contract-manifest.js +2 -2
  48. package/dist/tool-guidance.js +115 -7
  49. package/dist/tool-schemas.d.ts +1343 -149
  50. package/dist/tool-schemas.js +39 -7
  51. package/dist/types.d.ts +23 -0
  52. package/dist/workspace-mapping-service.d.ts +1 -0
  53. package/dist/workspace-mapping-service.js +120 -8
  54. package/docs/README-ja.md +4 -0
  55. package/docs/tool-reference.md +92 -6
  56. package/package.json +5 -5
@@ -6,7 +6,9 @@ import * as artifactResolver from "./artifact-resolver.js";
6
6
  import * as classSourceHelpers from "./class-source-helpers.js";
7
7
  import { buildClassSourceSnippet } from "./class-source/snippet-builder.js";
8
8
  import { remapAndCountMembers, sliceMembersWithLimit, projectMembersForWire, projectMembersByLevel } from "./class-source/members-builder.js";
9
+ import { collectDidYouMeanCandidates } from "./did-you-mean.js";
9
10
  import { matchesMemberPattern } from "./member-pattern.js";
11
+ import { findNestedJarClasses, resolveUniqueNestedJarForClass } from "./nested-jars.js";
10
12
  import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "../page-cursor.js";
11
13
  import { dedupeQualityFlags, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
12
14
  import { isUnobfuscatedVersion } from "../version-service.js";
@@ -80,6 +82,14 @@ function obfuscatedNamespaceHint(className) {
80
82
  function hasPartialNetMinecraftCoverage(qualityFlags) {
81
83
  return qualityFlags.includes("partial-source-no-net-minecraft");
82
84
  }
85
+ function shouldSuggestObfuscatedMapping(artifact, className) {
86
+ const nativeDependency = artifact.provenance?.dependencyResolution != null;
87
+ const shellArtifact = artifact.qualityFlags.includes("shell-jar");
88
+ return (artifact.mappingApplied === "obfuscated" &&
89
+ !nativeDependency &&
90
+ !shellArtifact &&
91
+ looksLikeDeobfuscatedClassName(className));
92
+ }
83
93
  function classNameToClassPath(className) {
84
94
  const normalized = normalizePathStyle(className.trim()).replace(/\//g, ".");
85
95
  const segments = normalized.split(".").filter((segment) => segment.length > 0);
@@ -165,7 +175,7 @@ export function buildFallbackProvenance(svc, input) {
165
175
  transformChain
166
176
  };
167
177
  }
168
- export function buildClassSourceNotFoundError(_svc, input) {
178
+ export function buildClassSourceNotFoundError(svc, input) {
169
179
  const simpleName = input.className.split(/[.$]/).at(-1) ?? input.className;
170
180
  const details = {
171
181
  artifactId: input.artifactId,
@@ -177,7 +187,11 @@ export function buildClassSourceNotFoundError(_svc, input) {
177
187
  ...(input.scope ? { scope: input.scope } : {}),
178
188
  ...(input.targetKind ? { targetKind: input.targetKind } : {}),
179
189
  ...(input.targetValue ? { targetValue: input.targetValue } : {}),
180
- ...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {})
190
+ ...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {}),
191
+ ...(input.nestedJars && input.nestedJars.length > 0 ? { nestedJars: input.nestedJars } : {}),
192
+ // Candidates are hints from the symbol index, never assertions that the
193
+ // class exists at the suggested location; empty when nothing usable.
194
+ didYouMean: collectDidYouMeanCandidates(svc, input.artifactId, input.className)
181
195
  };
182
196
  let nextAction = `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`;
183
197
  let suggestionSpec = {
@@ -220,6 +234,29 @@ export function buildClassSourceNotFoundError(_svc, input) {
220
234
  }
221
235
  details.nextAction = nextAction;
222
236
  Object.assign(details, buildSuggestedCall(suggestionSpec));
237
+ // Split-source workspaces can omit client-only classes from merged indexes;
238
+ // the vanilla scope decompiles the client jar, which contains them. Offer
239
+ // the retry as an example using existing scope enum values only.
240
+ if (input.targetKind === "version" && input.version && input.scope !== "vanilla") {
241
+ const scopeRetry = buildSuggestedCall({
242
+ tool: "get-class-source",
243
+ params: undefined,
244
+ examples: [
245
+ {
246
+ params: {
247
+ className: input.className,
248
+ target: { kind: "version", value: input.version },
249
+ scope: "vanilla"
250
+ },
251
+ reason: "Client-only classes can be missing from merged split-source indexes; scope \"vanilla\" decompiles the client jar, which contains them."
252
+ }
253
+ ]
254
+ });
255
+ if (scopeRetry.exampleCalls?.length) {
256
+ const existing = Array.isArray(details.exampleCalls) ? details.exampleCalls : [];
257
+ details.exampleCalls = [...existing, ...scopeRetry.exampleCalls];
258
+ }
259
+ }
223
260
  return createError({
224
261
  code: ERROR_CODES.CLASS_NOT_FOUND,
225
262
  message: `Source for class "${input.className}" was not found.`,
@@ -369,7 +406,7 @@ export function findClass(svc, input) {
369
406
  if (filteredMatches.length === 0 && partialVanillaLookup) {
370
407
  warnings.push(`Artifact source coverage is partial and excludes net.minecraft; returning non-vanilla matches for "${className}" would be misleading. Use get-class-source/get-class-members for binary fallback or get-class-api-matrix for mapped API inspection.`);
371
408
  }
372
- if (filteredMatches.length === 0 && artifact.mappingApplied === "obfuscated" && looksLikeDeobfuscatedClassName(className)) {
409
+ if (filteredMatches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
373
410
  warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
374
411
  }
375
412
  return { matches: filteredMatches, total: filteredMatches.length, warnings };
@@ -403,11 +440,39 @@ export function findClass(svc, input) {
403
440
  if (filteredMatches.length === 0 && partialVanillaLookup) {
404
441
  warnings.push(`Artifact source coverage is partial and excludes net.minecraft; returning non-vanilla matches for "${className}" would be misleading. Use get-class-source/get-class-members for binary fallback or get-class-api-matrix for mapped API inspection.`);
405
442
  }
406
- if (filteredMatches.length === 0 && artifact.mappingApplied === "obfuscated" && looksLikeDeobfuscatedClassName(className)) {
443
+ if (filteredMatches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
407
444
  warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
408
445
  }
409
446
  return { matches: filteredMatches, total: filteredMatches.length, warnings };
410
447
  }
448
+ export async function findClassIncludingNested(svc, input) {
449
+ const indexed = findClass(svc, input);
450
+ if (indexed.total > 0) {
451
+ return indexed;
452
+ }
453
+ const artifact = svc.getArtifact(input.artifactId.trim());
454
+ const inventory = artifact.provenance?.nestedJars;
455
+ if (!artifact.qualityFlags.includes("shell-jar") ||
456
+ !artifact.binaryJarPath ||
457
+ !inventory ||
458
+ inventory.length === 0) {
459
+ return indexed;
460
+ }
461
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 200));
462
+ const matches = await findNestedJarClasses({
463
+ cacheDir: svc.config.cacheDir,
464
+ outerJarPath: artifact.binaryJarPath,
465
+ outerSignature: artifact.artifactId,
466
+ inventory,
467
+ className: input.className,
468
+ limit
469
+ });
470
+ return {
471
+ matches,
472
+ total: matches.length,
473
+ warnings: indexed.warnings
474
+ };
475
+ }
411
476
  export async function getClassSource(svc, input) {
412
477
  const className = input.className.trim();
413
478
  if (!className) {
@@ -547,6 +612,53 @@ export async function getClassSource(svc, input) {
547
612
  }
548
613
  return true;
549
614
  };
615
+ let attemptedNestedJarRedirect = false;
616
+ const tryNestedJarRedirect = async (lookupClassName) => {
617
+ if (attemptedNestedJarRedirect) {
618
+ return false;
619
+ }
620
+ const inventory = activeProvenance?.nestedJars;
621
+ const outerJarPath = normalizeOptionalString(binaryJarPath);
622
+ if (!inventory || inventory.length === 0 || !outerJarPath) {
623
+ return false;
624
+ }
625
+ attemptedNestedJarRedirect = true;
626
+ const shellArtifactId = activeArtifactId;
627
+ const match = await resolveUniqueNestedJarForClass({
628
+ cacheDir: svc.config.cacheDir,
629
+ outerJarPath,
630
+ outerArtifactId: shellArtifactId,
631
+ inventory,
632
+ className: lookupClassName
633
+ });
634
+ if (!match) {
635
+ return false;
636
+ }
637
+ const redirectResolved = await svc.resolveArtifact({
638
+ target: { kind: "jar", value: match.extractedPath },
639
+ mapping: input.mapping,
640
+ sourcePriority: input.sourcePriority,
641
+ allowDecompile: input.allowDecompile,
642
+ projectPath: input.projectPath,
643
+ gradleUserHome: input.gradleUserHome
644
+ });
645
+ activeArtifactId = redirectResolved.artifactId;
646
+ activeOrigin = redirectResolved.origin;
647
+ activeMappingApplied = redirectResolved.mappingApplied ?? activeMappingApplied;
648
+ activeProvenance = redirectResolved.provenance
649
+ ? {
650
+ ...redirectResolved.provenance,
651
+ nestedJar: { entryName: match.entryName, shellArtifactId }
652
+ }
653
+ : activeProvenance;
654
+ activeQualityFlags = dedupeQualityFlags([
655
+ ...redirectResolved.qualityFlags,
656
+ "nested-jar-redirect"
657
+ ]);
658
+ activeSourceJarPath = redirectResolved.resolvedSourceJarPath;
659
+ warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; the lookup was redirected there automatically.`);
660
+ return true;
661
+ };
550
662
  let activeLookupClassName = await svc.resolveClassNameForLookup({
551
663
  className,
552
664
  version,
@@ -558,6 +670,9 @@ export async function getClassSource(svc, input) {
558
670
  context: "source lookup"
559
671
  });
560
672
  let filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
673
+ if (!filePath && (await tryNestedJarRedirect(activeLookupClassName))) {
674
+ filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
675
+ }
561
676
  if (!filePath && (await tryBinaryFallback())) {
562
677
  activeLookupClassName = await svc.resolveClassNameForLookup({
563
678
  className,
@@ -584,10 +699,18 @@ export async function getClassSource(svc, input) {
584
699
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
585
700
  scope: input.scope,
586
701
  projectPath: input.projectPath,
587
- version
702
+ version,
703
+ nestedJars: activeProvenance?.nestedJars
588
704
  });
589
705
  }
590
706
  let row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
707
+ if (!row && (await tryNestedJarRedirect(activeLookupClassName))) {
708
+ const redirectedFilePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
709
+ if (redirectedFilePath) {
710
+ filePath = redirectedFilePath;
711
+ row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
712
+ }
713
+ }
591
714
  if (!row && (await tryBinaryFallback())) {
592
715
  activeLookupClassName = await svc.resolveClassNameForLookup({
593
716
  className,
@@ -616,7 +739,8 @@ export async function getClassSource(svc, input) {
616
739
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
617
740
  scope: input.scope,
618
741
  projectPath: input.projectPath,
619
- version
742
+ version,
743
+ nestedJars: activeProvenance?.nestedJars
620
744
  });
621
745
  }
622
746
  const snippet = buildClassSourceSnippet({
@@ -841,22 +965,67 @@ export async function getClassMembers(svc, input) {
841
965
  let signatureMethods;
842
966
  let binaryExtractionFailed = false;
843
967
  let binaryExtractionFailureReason;
968
+ let nestedJarRedirect;
969
+ const fetchSignature = (jarPath) => svc.explorerService.getSignature({
970
+ fqn: lookupClassName,
971
+ jarPath,
972
+ access,
973
+ includeSynthetic,
974
+ includeInherited,
975
+ memberPattern: requestedMapping === mappingApplied ? memberPattern : undefined
976
+ });
844
977
  try {
845
- const signature = await svc.explorerService.getSignature({
846
- fqn: lookupClassName,
847
- jarPath: binaryJarPath,
848
- access,
849
- includeSynthetic,
850
- includeInherited,
851
- memberPattern: requestedMapping === mappingApplied ? memberPattern : undefined
852
- });
978
+ let signature;
979
+ try {
980
+ signature = await fetchSignature(binaryJarPath);
981
+ }
982
+ catch (missError) {
983
+ const inventory = provenance?.nestedJars;
984
+ if (!isAppError(missError) ||
985
+ missError.code !== ERROR_CODES.CLASS_NOT_FOUND ||
986
+ !inventory ||
987
+ inventory.length === 0) {
988
+ throw missError;
989
+ }
990
+ const match = await resolveUniqueNestedJarForClass({
991
+ cacheDir: svc.config.cacheDir,
992
+ outerJarPath: binaryJarPath,
993
+ outerArtifactId: artifactId,
994
+ inventory,
995
+ className: lookupClassName
996
+ });
997
+ if (!match) {
998
+ throw missError;
999
+ }
1000
+ nestedJarRedirect = { entryName: match.entryName, shellArtifactId: artifactId };
1001
+ warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; members were read from it.`);
1002
+ signature = await fetchSignature(match.extractedPath);
1003
+ }
853
1004
  warnings.push(...signature.warnings);
854
1005
  signatureContext = signature.context;
855
- signatureConstructors = signature.constructors;
856
- signatureFields = signature.fields;
857
- signatureMethods = signature.methods;
1006
+ // Member annotations are opt-in: strip them unless requested. The
1007
+ // annotationDefault of annotation-type members is always kept. Cached
1008
+ // signature objects must not be mutated, so stripping copies.
1009
+ const stripAnnotations = (member) => {
1010
+ if (!member.annotations) {
1011
+ return member;
1012
+ }
1013
+ const { annotations: _omitted, ...rest } = member;
1014
+ return rest;
1015
+ };
1016
+ const includeAnnotations = input.includeAnnotations ?? false;
1017
+ signatureConstructors = includeAnnotations
1018
+ ? signature.constructors
1019
+ : signature.constructors.map(stripAnnotations);
1020
+ signatureFields = includeAnnotations ? signature.fields : signature.fields.map(stripAnnotations);
1021
+ signatureMethods = includeAnnotations ? signature.methods : signature.methods.map(stripAnnotations);
858
1022
  }
859
1023
  catch (error) {
1024
+ if (isAppError(error) && error.code === ERROR_CODES.NESTED_JAR_AMBIGUOUS) {
1025
+ // A class living in several nested jars needs the caller's choice; the
1026
+ // candidates error must not degrade into a members_unavailable response.
1027
+ throw error;
1028
+ }
860
1029
  if (isAppError(error) && error.code === ERROR_CODES.CLASS_NOT_FOUND) {
861
1030
  // Re-raise with the shared recovery shape (find-class/api-matrix
862
1031
  // suggestedCall, namespace + scope hints) instead of the sparse bytecode
@@ -873,7 +1042,8 @@ export async function getClassMembers(svc, input) {
873
1042
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
874
1043
  scope: input.scope,
875
1044
  projectPath: input.projectPath,
876
- version
1045
+ version,
1046
+ nestedJars: provenance?.nestedJars
877
1047
  });
878
1048
  }
879
1049
  binaryExtractionFailed = true;
@@ -926,13 +1096,16 @@ export async function getClassMembers(svc, input) {
926
1096
  const projectedMembers = projectMembersByLevel(projectMembersForWire({ constructors, fields, methods }, includeInherited, input.includeDescriptors ?? false), projection);
927
1097
  const truncated = sliced.truncated;
928
1098
  const nextCursor = sliced.nextOffset != null ? encodeOffsetCursor(sliced.nextOffset, memberCursorContext) : undefined;
929
- const normalizedProvenance = provenance ??
1099
+ const baseProvenance = provenance ??
930
1100
  buildFallbackProvenance(svc, {
931
1101
  artifactId,
932
1102
  origin,
933
1103
  requestedMapping,
934
1104
  mappingApplied
935
1105
  });
1106
+ const normalizedProvenance = nestedJarRedirect
1107
+ ? { ...baseProvenance, nestedJar: nestedJarRedirect }
1108
+ : baseProvenance;
936
1109
  let decompiledFallback;
937
1110
  let decompiledMemberCounts;
938
1111
  let fallbackQualityFlags = qualityFlags;
@@ -0,0 +1,14 @@
1
+ import type { SourceService } from "../source-service.js";
2
+ export interface DidYouMeanCandidate {
3
+ className: string;
4
+ matchReason: string;
5
+ }
6
+ /**
7
+ * Collects ranked near-miss class candidates from the artifact's symbol
8
+ * index for a class that was not found: exact simple-name matches first
9
+ * (the moved-FQN case), then case-insensitive matches, then bounded
10
+ * edit-distance suggestions. Candidates are hints, never assertions that the
11
+ * class exists at the suggested location. Returns an empty array when the
12
+ * index has nothing usable.
13
+ */
14
+ export declare function collectDidYouMeanCandidates(svc: SourceService, artifactId: string, className: string): DidYouMeanCandidate[];
@@ -0,0 +1,79 @@
1
+ import { levenshteinDistance, suggestSimilar } from "../mixin/helpers.js";
2
+ const TYPE_SYMBOL_KINDS = ["class", "interface", "enum", "record"];
3
+ const MAX_CANDIDATES = 8;
4
+ // Near-miss candidates come from a bounded prefix pool: typos inside the
5
+ // first characters are rare compared to suffix/mid-word slips, and an
6
+ // unbounded scan over every type symbol would not stay index-backed.
7
+ const EDIT_DISTANCE_POOL_PREFIX = 4;
8
+ function fqnOfRow(row) {
9
+ return row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
10
+ }
11
+ /**
12
+ * Collects ranked near-miss class candidates from the artifact's symbol
13
+ * index for a class that was not found: exact simple-name matches first
14
+ * (the moved-FQN case), then case-insensitive matches, then bounded
15
+ * edit-distance suggestions. Candidates are hints, never assertions that the
16
+ * class exists at the suggested location. Returns an empty array when the
17
+ * index has nothing usable.
18
+ */
19
+ export function collectDidYouMeanCandidates(svc, artifactId, className) {
20
+ try {
21
+ const simpleName = className.split(/[.$]/).at(-1) ?? className;
22
+ if (!simpleName) {
23
+ return [];
24
+ }
25
+ const out = [];
26
+ const seen = new Set([className]);
27
+ const push = (fqn, matchReason) => {
28
+ if (seen.has(fqn) || out.length >= MAX_CANDIDATES) {
29
+ return;
30
+ }
31
+ seen.add(fqn);
32
+ out.push({ className: fqn, matchReason });
33
+ };
34
+ const exact = svc.symbolsRepo.findScopedSymbols({
35
+ artifactId,
36
+ query: simpleName,
37
+ match: "exact",
38
+ symbolKinds: TYPE_SYMBOL_KINDS,
39
+ limit: 50
40
+ });
41
+ for (const row of exact.items) {
42
+ push(fqnOfRow(row), "exact-simple-name");
43
+ }
44
+ // One index-backed prefix query (lower(symbol_name) LIKE 'pref%') feeds
45
+ // both the case-insensitive and the edit-distance tiers: a case variant
46
+ // shares the lowercased prefix, and a leading-wildcard `contains` scan
47
+ // would defeat the symbol-name index on every not-found error.
48
+ const prefixPool = svc.symbolsRepo.findScopedSymbols({
49
+ artifactId,
50
+ query: simpleName.slice(0, EDIT_DISTANCE_POOL_PREFIX),
51
+ match: "prefix",
52
+ symbolKinds: TYPE_SYMBOL_KINDS,
53
+ limit: 200
54
+ });
55
+ for (const row of prefixPool.items) {
56
+ if (row.symbolName === simpleName)
57
+ continue;
58
+ if (row.symbolName.toLowerCase() !== simpleName.toLowerCase())
59
+ continue;
60
+ push(fqnOfRow(row), "case-insensitive");
61
+ }
62
+ const poolNames = [...new Set(prefixPool.items.map((row) => row.symbolName))];
63
+ for (const suggestion of suggestSimilar(simpleName, poolNames)) {
64
+ const distance = levenshteinDistance(simpleName.toLowerCase(), suggestion.toLowerCase());
65
+ for (const row of prefixPool.items) {
66
+ if (row.symbolName !== suggestion)
67
+ continue;
68
+ push(fqnOfRow(row), `edit-distance:${distance}`);
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ catch {
74
+ // Candidate collection must never turn the not-found error into a
75
+ // different failure; a broken index simply yields no suggestions.
76
+ return [];
77
+ }
78
+ }
79
+ //# sourceMappingURL=did-you-mean.js.map
@@ -1,6 +1,64 @@
1
- import { ERROR_CODES, createError } from "../errors.js";
1
+ import { basename } from "node:path";
2
+ import { ERROR_CODES, createError, isAppError } from "../errors.js";
2
3
  import { log } from "../logger.js";
4
+ import { decodeJarEntryUtf8OrThrow, listJarEntries, readJarEntryCapped } from "../source-jar-reader.js";
3
5
  import { normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
6
+ // Read-through delivery for non-indexed jar resources: text files under these
7
+ // prefixes are served directly from the backing jar when the source index has
8
+ // no row for them. The per-file cap bounds response size; binary entries
9
+ // answer with metadata only.
10
+ const READ_THROUGH_PREFIXES = ["assets/", "data/"];
11
+ const READ_THROUGH_MAX_BYTES = 512 * 1024;
12
+ const READ_THROUGH_TEXT_EXTENSIONS = new Set([
13
+ ".json",
14
+ ".mcmeta",
15
+ ".txt",
16
+ ".properties",
17
+ ".lang",
18
+ ".cfg",
19
+ ".toml",
20
+ ".snbt",
21
+ ".yml",
22
+ ".yaml",
23
+ ".csv",
24
+ ".md",
25
+ ".fsh",
26
+ ".vsh",
27
+ ".glsl"
28
+ ]);
29
+ const NEARBY_PATH_HINT_LIMIT = 5;
30
+ function isTraversalShapedPath(filePath) {
31
+ return (filePath.startsWith("/") ||
32
+ filePath.includes("\u0000") ||
33
+ filePath.split(/[\\/]/).includes(".."));
34
+ }
35
+ function hasReadThroughPrefix(filePath) {
36
+ return READ_THROUGH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
37
+ }
38
+ function readThroughTextExtension(filePath) {
39
+ const dot = filePath.lastIndexOf(".");
40
+ if (dot < 0) {
41
+ return false;
42
+ }
43
+ return READ_THROUGH_TEXT_EXTENSIONS.has(filePath.slice(dot).toLowerCase());
44
+ }
45
+ /**
46
+ * Same-basename entries elsewhere in the jar, for not-found hints. Covers
47
+ * directory relocations across versions (e.g. assets/minecraft/models/item/*
48
+ * moving to assets/minecraft/items/*).
49
+ */
50
+ async function collectNearbyPaths(binaryJarPath, missingPath) {
51
+ try {
52
+ const wanted = basename(missingPath);
53
+ const entries = await listJarEntries(binaryJarPath);
54
+ return entries
55
+ .filter((entry) => hasReadThroughPrefix(entry) && basename(entry) === wanted)
56
+ .slice(0, NEARBY_PATH_HINT_LIMIT);
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ }
4
62
  function clampLimit(limit, fallback, max) {
5
63
  if (limit === undefined || limit === null) {
6
64
  return fallback;
@@ -24,8 +82,28 @@ function truncateUtf8ToMaxBytes(content, maxBytes) {
24
82
  export async function getArtifactFile(svc, input) {
25
83
  const startedAt = Date.now();
26
84
  try {
85
+ if (isTraversalShapedPath(input.filePath)) {
86
+ throw createError({
87
+ code: ERROR_CODES.INVALID_INPUT,
88
+ message: `filePath "${input.filePath}" must be a plain in-archive path (no absolute paths or ".." segments).`,
89
+ details: {
90
+ filePath: input.filePath,
91
+ nextAction: "Pass the entry path exactly as it appears inside the jar, e.g. assets/minecraft/models/block/stone.json."
92
+ }
93
+ });
94
+ }
27
95
  const artifact = svc.getArtifact(input.artifactId);
28
- const row = svc.filesRepo.getFileContent(artifact.artifactId, normalizePathStyle(input.filePath));
96
+ const normalizedPath = normalizePathStyle(input.filePath);
97
+ const row = svc.filesRepo.getFileContent(artifact.artifactId, normalizedPath);
98
+ if (!row && hasReadThroughPrefix(normalizedPath) && artifact.binaryJarPath) {
99
+ return await readFileThroughJar(svc, {
100
+ artifact,
101
+ binaryJarPath: artifact.binaryJarPath,
102
+ filePath: normalizedPath,
103
+ maxBytes: input.maxBytes,
104
+ artifactId: input.artifactId
105
+ });
106
+ }
29
107
  if (!row) {
30
108
  throw createError({
31
109
  code: ERROR_CODES.FILE_NOT_FOUND,
@@ -65,6 +143,84 @@ export async function getArtifactFile(svc, input) {
65
143
  svc.metrics.recordDuration("get_file_duration_ms", Date.now() - startedAt);
66
144
  }
67
145
  }
146
+ async function readFileThroughJar(svc, args) {
147
+ const { artifact, binaryJarPath, filePath, artifactId } = args;
148
+ const isText = readThroughTextExtension(filePath);
149
+ const cap = isText
150
+ ? Math.min(clampLimit(args.maxBytes, svc.config.maxContentBytes, Number.MAX_SAFE_INTEGER), READ_THROUGH_MAX_BYTES)
151
+ : 0;
152
+ let capped;
153
+ try {
154
+ // Read at most the cap (+ slack to trim back to a UTF-8 boundary); an
155
+ // oversized entry is never fully materialized in memory. Binary entries
156
+ // are metadata-only probes (no content read at all).
157
+ capped = await readJarEntryCapped(binaryJarPath, filePath, isText ? cap + 4 : 0);
158
+ }
159
+ catch (error) {
160
+ if (!isAppError(error) || error.code !== ERROR_CODES.SOURCE_NOT_FOUND) {
161
+ // Unsafe paths and unreadable/corrupt jars are their own failures;
162
+ // only a genuinely-missing entry becomes file-not-found with hints.
163
+ throw error;
164
+ }
165
+ const nearbyPaths = await collectNearbyPaths(binaryJarPath, filePath);
166
+ throw createError({
167
+ code: ERROR_CODES.FILE_NOT_FOUND,
168
+ message: `File "${filePath}" was not found in the source index or the backing jar.`,
169
+ details: {
170
+ artifactId,
171
+ filePath,
172
+ ...(nearbyPaths.length > 0
173
+ ? {
174
+ nearbyPaths,
175
+ nextAction: `Same-named entries exist at: ${nearbyPaths.join(", ")}. Directory layouts move between versions; retry with one of those paths.`
176
+ }
177
+ : {})
178
+ }
179
+ });
180
+ }
181
+ const base = {
182
+ filePath,
183
+ mappingApplied: artifact.mappingApplied ?? "obfuscated",
184
+ returnedNamespace: artifact.mappingApplied ?? "obfuscated",
185
+ artifactContents: svc.buildArtifactContentsSummary({
186
+ origin: artifact.origin,
187
+ sourceJarPath: artifact.sourceJarPath,
188
+ isDecompiled: artifact.isDecompiled,
189
+ qualityFlags: artifact.qualityFlags
190
+ }),
191
+ deliveryMode: "jar-read-through"
192
+ };
193
+ if (!isText) {
194
+ return {
195
+ ...base,
196
+ content: "",
197
+ contentBytes: capped.entrySize,
198
+ truncated: false,
199
+ contentOmittedReason: "Entry is not a known text format; binary content is not delivered. Size and existence are reported instead."
200
+ };
201
+ }
202
+ const truncated = capped.entrySize > cap;
203
+ let content;
204
+ if (truncated) {
205
+ // Trim the capped prefix back to a UTF-8 character boundary before
206
+ // decoding; the tail past the cap is dropped by design.
207
+ const buffer = capped.buffer;
208
+ let cut = Math.min(cap, buffer.length);
209
+ while (cut > 0 && ((buffer[cut] ?? 0) & 0xc0) === 0x80) {
210
+ cut -= 1;
211
+ }
212
+ content = buffer.slice(0, cut).toString("utf8");
213
+ }
214
+ else {
215
+ content = decodeJarEntryUtf8OrThrow(capped.buffer, binaryJarPath, filePath);
216
+ }
217
+ return {
218
+ ...base,
219
+ content,
220
+ contentBytes: capped.entrySize,
221
+ truncated
222
+ };
223
+ }
68
224
  export async function listArtifactFiles(svc, input) {
69
225
  const startedAt = Date.now();
70
226
  try {
@@ -81,7 +237,7 @@ export async function listArtifactFiles(svc, input) {
81
237
  if (normalizedPrefix &&
82
238
  page.items.length === 0 &&
83
239
  (normalizedPrefix.startsWith("assets/") || normalizedPrefix.startsWith("data/"))) {
84
- warnings.push("Indexed artifacts currently include Java source only; non-Java resources are not indexed. Inspect the original jar on disk if you need assets or data files.");
240
+ warnings.push("Indexed artifacts currently include Java source only; non-Java resources are not indexed. Text files under assets/ and data/ are served directly from the backing jar request them by exact path with get-artifact-file (read-through delivery).");
85
241
  }
86
242
  return {
87
243
  items: page.items,