@adhisang/minecraft-modding-mcp 6.1.0 → 6.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -2
  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 +2 -0
  8. package/dist/entry-tools/analyze-symbol-service.js +37 -2
  9. package/dist/entry-tools/inspect-minecraft/internal.d.ts +4 -0
  10. package/dist/entry-tools/inspect-minecraft/internal.js +28 -0
  11. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  12. package/dist/error-mapping.d.ts +13 -0
  13. package/dist/error-mapping.js +35 -2
  14. package/dist/errors.d.ts +2 -0
  15. package/dist/errors.js +2 -0
  16. package/dist/index.js +37 -17
  17. package/dist/mapping/internal-types.d.ts +7 -0
  18. package/dist/mapping/types.d.ts +18 -0
  19. package/dist/mapping-service.js +16 -3
  20. package/dist/minecraft-explorer-service.d.ts +4 -0
  21. package/dist/minecraft-explorer-service.js +156 -17
  22. package/dist/mod-analyzer.d.ts +7 -0
  23. package/dist/mod-analyzer.js +28 -7
  24. package/dist/nbt/typed-json.js +4 -1
  25. package/dist/source/artifact-resolver.d.ts +2 -0
  26. package/dist/source/artifact-resolver.js +24 -1
  27. package/dist/source/class-source/members-builder.d.ts +4 -0
  28. package/dist/source/class-source/members-builder.js +3 -1
  29. package/dist/source/class-source.d.ts +2 -1
  30. package/dist/source/class-source.js +154 -17
  31. package/dist/source/did-you-mean.d.ts +14 -0
  32. package/dist/source/did-you-mean.js +79 -0
  33. package/dist/source/file-access.js +159 -3
  34. package/dist/source/indexer.js +72 -2
  35. package/dist/source/lifecycle/runtime-check.js +15 -7
  36. package/dist/source/nested-jars.d.ts +59 -0
  37. package/dist/source/nested-jars.js +198 -0
  38. package/dist/source/workspace-target.js +5 -2
  39. package/dist/source-jar-reader.d.ts +16 -0
  40. package/dist/source-jar-reader.js +82 -0
  41. package/dist/source-service.d.ts +36 -0
  42. package/dist/source-service.js +49 -6
  43. package/dist/stage-emitter.js +24 -8
  44. package/dist/stdio-supervisor.d.ts +92 -9
  45. package/dist/stdio-supervisor.js +915 -103
  46. package/dist/tool-contract-manifest.js +1 -1
  47. package/dist/tool-guidance.js +3 -1
  48. package/dist/tool-schemas.d.ts +1337 -149
  49. package/dist/tool-schemas.js +38 -7
  50. package/dist/types.d.ts +23 -0
  51. package/dist/workspace-mapping-service.d.ts +1 -0
  52. package/dist/workspace-mapping-service.js +120 -8
  53. package/docs/tool-reference.md +19 -3
  54. package/package.json +1 -1
@@ -206,13 +206,32 @@ export async function discoverVersionSourceJar(_svc, input) {
206
206
  const candidateArtifacts = candidates
207
207
  .slice(0, 20)
208
208
  .map((candidate) => `${candidate.jarPath}#java=${candidate.javaEntryCount}#net_minecraft=${candidate.hasMinecraftNamespace ? 1 : 0}`);
209
+ // Loom split-source workspaces publish the version as a common/clientOnly
210
+ // PAIR with no merged jar; selecting one loses the other half's classes
211
+ // (e.g. net.minecraft.client.* when common wins). Surface the best-scored
212
+ // jar of the other half so ingestion can index both.
213
+ const selectedHalf = selected ? splitSourceHalf(selected.jarPath) : undefined;
214
+ const companion = selectedHalf
215
+ ? candidates.find((candidate) => candidate !== selected &&
216
+ candidate.looksLikeMinecraftArtifact &&
217
+ splitSourceHalf(candidate.jarPath) !== undefined &&
218
+ splitSourceHalf(candidate.jarPath) !== selectedHalf &&
219
+ // Version affinity: a leftover other-half jar from a different
220
+ // version must never be spliced into this version's index.
221
+ hasExactVersionToken(candidate.jarPath, input.version))
222
+ : undefined;
209
223
  return {
210
224
  searchedPaths,
211
225
  candidateArtifacts,
212
226
  selectedSourceJarPath: selected?.jarPath,
213
- selectedHasMinecraftNamespace: selected?.hasMinecraftNamespace
227
+ selectedHasMinecraftNamespace: selected?.hasMinecraftNamespace,
228
+ ...(companion ? { companionSourceJarPaths: [companion.jarPath] } : {})
214
229
  };
215
230
  }
231
+ function splitSourceHalf(jarPath) {
232
+ const match = /minecraft-(common|clientonly)/i.exec(jarPath);
233
+ return match ? match[1].toLowerCase() : undefined;
234
+ }
216
235
  export async function probeMinecraftArtifact(svc, input) {
217
236
  let value = input.target.value.trim();
218
237
  const warnings = [];
@@ -1171,6 +1190,10 @@ export async function resolveArtifact(svc, input) {
1171
1190
  if (dependencyProvenance) {
1172
1191
  provenance.dependencyResolution = dependencyProvenance;
1173
1192
  }
1193
+ if (versionSourceDiscovery?.companionSourceJarPaths?.length &&
1194
+ resolved.sourceJarPath === versionSourceDiscovery.selectedSourceJarPath) {
1195
+ provenance.companionSourceJars = versionSourceDiscovery.companionSourceJarPaths;
1196
+ }
1174
1197
  if (dependencyOrigin && dependencyRequestedMapping && dependencyRequestedMapping !== "obfuscated") {
1175
1198
  const coord = resolved.coordinate ?? value;
1176
1199
  warnings.push(`Dependency artifact ${coord} mapping "${dependencyRequestedMapping}" is not enforced (binary remap is disabled for non-vanilla artifacts); the JAR is returned in its native namespace and mappingApplied is reported as "obfuscated" with qualityFlag "dependency-mapping-unverified". Caller must validate symbol availability.`);
@@ -50,6 +50,10 @@ export type WireMember = {
50
50
  ownerFqn?: string;
51
51
  /** Present only when true. */
52
52
  isSynthetic?: boolean;
53
+ /** Rendered default value of an annotation-type member. */
54
+ annotationDefault?: string;
55
+ /** Runtime-visible annotations; present only under the opt-in projection. */
56
+ annotations?: string[];
53
57
  };
54
58
  export type WireMembersBlock = {
55
59
  /** Hoisted owner when every member shares one owner (the common, non-inherited case). */
@@ -82,7 +82,9 @@ export function projectMembersForWire(slice, includeInherited, keepFieldDescript
82
82
  javaSignature: m.javaSignature,
83
83
  ...(keepDescriptor ? { jvmDescriptor: m.jvmDescriptor } : {}),
84
84
  ...(hoistOwner ? {} : { ownerFqn: m.ownerFqn }),
85
- ...(m.isSynthetic ? { isSynthetic: true } : {})
85
+ ...(m.isSynthetic ? { isSynthetic: true } : {}),
86
+ ...(m.annotationDefault !== undefined ? { annotationDefault: m.annotationDefault } : {}),
87
+ ...(m.annotations?.length ? { annotations: m.annotations } : {})
86
88
  });
87
89
  return {
88
90
  ...(hoistOwner ? { ownerFqn: [...owners][0] } : {}),
@@ -37,7 +37,7 @@ export declare function buildFallbackProvenance(svc: SourceService, input: {
37
37
  requestedMapping: SourceMapping;
38
38
  mappingApplied: SourceMapping;
39
39
  }): ArtifactProvenance;
40
- export declare function buildClassSourceNotFoundError(_svc: SourceService, input: {
40
+ export declare function buildClassSourceNotFoundError(svc: SourceService, input: {
41
41
  className: string;
42
42
  lookupClassName: string;
43
43
  artifactId: string;
@@ -51,6 +51,7 @@ export declare function buildClassSourceNotFoundError(_svc: SourceService, input
51
51
  scope?: ArtifactScope;
52
52
  projectPath?: string;
53
53
  version?: string;
54
+ nestedJars?: string[];
54
55
  }): AppError;
55
56
  export declare function buildDecompiledFallback(svc: SourceService, artifactId: string, lookupClassName: string, memberPattern: string | undefined, maxMembers: number): {
56
57
  fallback: DecompiledFallback;
@@ -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 { 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";
@@ -165,7 +167,7 @@ export function buildFallbackProvenance(svc, input) {
165
167
  transformChain
166
168
  };
167
169
  }
168
- export function buildClassSourceNotFoundError(_svc, input) {
170
+ export function buildClassSourceNotFoundError(svc, input) {
169
171
  const simpleName = input.className.split(/[.$]/).at(-1) ?? input.className;
170
172
  const details = {
171
173
  artifactId: input.artifactId,
@@ -177,7 +179,11 @@ export function buildClassSourceNotFoundError(_svc, input) {
177
179
  ...(input.scope ? { scope: input.scope } : {}),
178
180
  ...(input.targetKind ? { targetKind: input.targetKind } : {}),
179
181
  ...(input.targetValue ? { targetValue: input.targetValue } : {}),
180
- ...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {})
182
+ ...(input.attemptedBinaryFallback ? { binaryFallbackAttempted: true } : {}),
183
+ ...(input.nestedJars && input.nestedJars.length > 0 ? { nestedJars: input.nestedJars } : {}),
184
+ // Candidates are hints from the symbol index, never assertions that the
185
+ // class exists at the suggested location; empty when nothing usable.
186
+ didYouMean: collectDidYouMeanCandidates(svc, input.artifactId, input.className)
181
187
  };
182
188
  let nextAction = `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`;
183
189
  let suggestionSpec = {
@@ -220,6 +226,29 @@ export function buildClassSourceNotFoundError(_svc, input) {
220
226
  }
221
227
  details.nextAction = nextAction;
222
228
  Object.assign(details, buildSuggestedCall(suggestionSpec));
229
+ // Split-source workspaces can omit client-only classes from merged indexes;
230
+ // the vanilla scope decompiles the client jar, which contains them. Offer
231
+ // the retry as an example using existing scope enum values only.
232
+ if (input.targetKind === "version" && input.version && input.scope !== "vanilla") {
233
+ const scopeRetry = buildSuggestedCall({
234
+ tool: "get-class-source",
235
+ params: undefined,
236
+ examples: [
237
+ {
238
+ params: {
239
+ className: input.className,
240
+ target: { kind: "version", value: input.version },
241
+ scope: "vanilla"
242
+ },
243
+ reason: "Client-only classes can be missing from merged split-source indexes; scope \"vanilla\" decompiles the client jar, which contains them."
244
+ }
245
+ ]
246
+ });
247
+ if (scopeRetry.exampleCalls?.length) {
248
+ const existing = Array.isArray(details.exampleCalls) ? details.exampleCalls : [];
249
+ details.exampleCalls = [...existing, ...scopeRetry.exampleCalls];
250
+ }
251
+ }
223
252
  return createError({
224
253
  code: ERROR_CODES.CLASS_NOT_FOUND,
225
254
  message: `Source for class "${input.className}" was not found.`,
@@ -547,6 +576,53 @@ export async function getClassSource(svc, input) {
547
576
  }
548
577
  return true;
549
578
  };
579
+ let attemptedNestedJarRedirect = false;
580
+ const tryNestedJarRedirect = async (lookupClassName) => {
581
+ if (attemptedNestedJarRedirect) {
582
+ return false;
583
+ }
584
+ const inventory = activeProvenance?.nestedJars;
585
+ const outerJarPath = normalizeOptionalString(binaryJarPath);
586
+ if (!inventory || inventory.length === 0 || !outerJarPath) {
587
+ return false;
588
+ }
589
+ attemptedNestedJarRedirect = true;
590
+ const shellArtifactId = activeArtifactId;
591
+ const match = await resolveUniqueNestedJarForClass({
592
+ cacheDir: svc.config.cacheDir,
593
+ outerJarPath,
594
+ outerArtifactId: shellArtifactId,
595
+ inventory,
596
+ className: lookupClassName
597
+ });
598
+ if (!match) {
599
+ return false;
600
+ }
601
+ const redirectResolved = await svc.resolveArtifact({
602
+ target: { kind: "jar", value: match.extractedPath },
603
+ mapping: input.mapping,
604
+ sourcePriority: input.sourcePriority,
605
+ allowDecompile: input.allowDecompile,
606
+ projectPath: input.projectPath,
607
+ gradleUserHome: input.gradleUserHome
608
+ });
609
+ activeArtifactId = redirectResolved.artifactId;
610
+ activeOrigin = redirectResolved.origin;
611
+ activeMappingApplied = redirectResolved.mappingApplied ?? activeMappingApplied;
612
+ activeProvenance = redirectResolved.provenance
613
+ ? {
614
+ ...redirectResolved.provenance,
615
+ nestedJar: { entryName: match.entryName, shellArtifactId }
616
+ }
617
+ : activeProvenance;
618
+ activeQualityFlags = dedupeQualityFlags([
619
+ ...redirectResolved.qualityFlags,
620
+ "nested-jar-redirect"
621
+ ]);
622
+ activeSourceJarPath = redirectResolved.resolvedSourceJarPath;
623
+ warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; the lookup was redirected there automatically.`);
624
+ return true;
625
+ };
550
626
  let activeLookupClassName = await svc.resolveClassNameForLookup({
551
627
  className,
552
628
  version,
@@ -558,6 +634,9 @@ export async function getClassSource(svc, input) {
558
634
  context: "source lookup"
559
635
  });
560
636
  let filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
637
+ if (!filePath && (await tryNestedJarRedirect(activeLookupClassName))) {
638
+ filePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
639
+ }
561
640
  if (!filePath && (await tryBinaryFallback())) {
562
641
  activeLookupClassName = await svc.resolveClassNameForLookup({
563
642
  className,
@@ -584,10 +663,18 @@ export async function getClassSource(svc, input) {
584
663
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
585
664
  scope: input.scope,
586
665
  projectPath: input.projectPath,
587
- version
666
+ version,
667
+ nestedJars: activeProvenance?.nestedJars
588
668
  });
589
669
  }
590
670
  let row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
671
+ if (!row && (await tryNestedJarRedirect(activeLookupClassName))) {
672
+ const redirectedFilePath = resolveClassFilePath(svc, activeArtifactId, activeLookupClassName);
673
+ if (redirectedFilePath) {
674
+ filePath = redirectedFilePath;
675
+ row = svc.filesRepo.getFileContent(activeArtifactId, filePath);
676
+ }
677
+ }
591
678
  if (!row && (await tryBinaryFallback())) {
592
679
  activeLookupClassName = await svc.resolveClassNameForLookup({
593
680
  className,
@@ -616,7 +703,8 @@ export async function getClassSource(svc, input) {
616
703
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
617
704
  scope: input.scope,
618
705
  projectPath: input.projectPath,
619
- version
706
+ version,
707
+ nestedJars: activeProvenance?.nestedJars
620
708
  });
621
709
  }
622
710
  const snippet = buildClassSourceSnippet({
@@ -841,22 +929,67 @@ export async function getClassMembers(svc, input) {
841
929
  let signatureMethods;
842
930
  let binaryExtractionFailed = false;
843
931
  let binaryExtractionFailureReason;
932
+ let nestedJarRedirect;
933
+ const fetchSignature = (jarPath) => svc.explorerService.getSignature({
934
+ fqn: lookupClassName,
935
+ jarPath,
936
+ access,
937
+ includeSynthetic,
938
+ includeInherited,
939
+ memberPattern: requestedMapping === mappingApplied ? memberPattern : undefined
940
+ });
844
941
  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
- });
942
+ let signature;
943
+ try {
944
+ signature = await fetchSignature(binaryJarPath);
945
+ }
946
+ catch (missError) {
947
+ const inventory = provenance?.nestedJars;
948
+ if (!isAppError(missError) ||
949
+ missError.code !== ERROR_CODES.CLASS_NOT_FOUND ||
950
+ !inventory ||
951
+ inventory.length === 0) {
952
+ throw missError;
953
+ }
954
+ const match = await resolveUniqueNestedJarForClass({
955
+ cacheDir: svc.config.cacheDir,
956
+ outerJarPath: binaryJarPath,
957
+ outerArtifactId: artifactId,
958
+ inventory,
959
+ className: lookupClassName
960
+ });
961
+ if (!match) {
962
+ throw missError;
963
+ }
964
+ nestedJarRedirect = { entryName: match.entryName, shellArtifactId: artifactId };
965
+ warnings.push(`Class "${className}" lives in nested jar "${match.entryName}" bundled by the shell jar; members were read from it.`);
966
+ signature = await fetchSignature(match.extractedPath);
967
+ }
853
968
  warnings.push(...signature.warnings);
854
969
  signatureContext = signature.context;
855
- signatureConstructors = signature.constructors;
856
- signatureFields = signature.fields;
857
- signatureMethods = signature.methods;
970
+ // Member annotations are opt-in: strip them unless requested. The
971
+ // annotationDefault of annotation-type members is always kept. Cached
972
+ // signature objects must not be mutated, so stripping copies.
973
+ const stripAnnotations = (member) => {
974
+ if (!member.annotations) {
975
+ return member;
976
+ }
977
+ const { annotations: _omitted, ...rest } = member;
978
+ return rest;
979
+ };
980
+ const includeAnnotations = input.includeAnnotations ?? false;
981
+ signatureConstructors = includeAnnotations
982
+ ? signature.constructors
983
+ : signature.constructors.map(stripAnnotations);
984
+ signatureFields = includeAnnotations ? signature.fields : signature.fields.map(stripAnnotations);
985
+ signatureMethods = includeAnnotations ? signature.methods : signature.methods.map(stripAnnotations);
858
986
  }
859
987
  catch (error) {
988
+ if (isAppError(error) && error.code === ERROR_CODES.NESTED_JAR_AMBIGUOUS) {
989
+ // A class living in several nested jars needs the caller's choice; the
990
+ // candidates error must not degrade into a members_unavailable response.
991
+ throw error;
992
+ }
860
993
  if (isAppError(error) && error.code === ERROR_CODES.CLASS_NOT_FOUND) {
861
994
  // Re-raise with the shared recovery shape (find-class/api-matrix
862
995
  // suggestedCall, namespace + scope hints) instead of the sparse bytecode
@@ -873,7 +1006,8 @@ export async function getClassMembers(svc, input) {
873
1006
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
874
1007
  scope: input.scope,
875
1008
  projectPath: input.projectPath,
876
- version
1009
+ version,
1010
+ nestedJars: provenance?.nestedJars
877
1011
  });
878
1012
  }
879
1013
  binaryExtractionFailed = true;
@@ -926,13 +1060,16 @@ export async function getClassMembers(svc, input) {
926
1060
  const projectedMembers = projectMembersByLevel(projectMembersForWire({ constructors, fields, methods }, includeInherited, input.includeDescriptors ?? false), projection);
927
1061
  const truncated = sliced.truncated;
928
1062
  const nextCursor = sliced.nextOffset != null ? encodeOffsetCursor(sliced.nextOffset, memberCursorContext) : undefined;
929
- const normalizedProvenance = provenance ??
1063
+ const baseProvenance = provenance ??
930
1064
  buildFallbackProvenance(svc, {
931
1065
  artifactId,
932
1066
  origin,
933
1067
  requestedMapping,
934
1068
  mappingApplied
935
1069
  });
1070
+ const normalizedProvenance = nestedJarRedirect
1071
+ ? { ...baseProvenance, nestedJar: nestedJarRedirect }
1072
+ : baseProvenance;
936
1073
  let decompiledFallback;
937
1074
  let decompiledMemberCounts;
938
1075
  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,