@adhisang/minecraft-modding-mcp 6.2.0 → 7.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +21 -5
  3. package/dist/cache-policy.d.ts +71 -0
  4. package/dist/cache-policy.js +83 -0
  5. package/dist/cache-registry.js +6 -6
  6. package/dist/cli.js +74 -3
  7. package/dist/compat-stdio-transport.d.ts +1 -1
  8. package/dist/compat-stdio-transport.js +13 -1
  9. package/dist/config.d.ts +3 -0
  10. package/dist/config.js +8 -2
  11. package/dist/decompiler/vineflower.d.ts +1 -0
  12. package/dist/decompiler/vineflower.js +8 -5
  13. package/dist/entry-tools/analyze-mod-service.d.ts +70 -136
  14. package/dist/entry-tools/analyze-symbol-service.d.ts +112 -150
  15. package/dist/entry-tools/compare-minecraft-service.d.ts +59 -145
  16. package/dist/entry-tools/entry-tool-schema.d.ts +38 -4
  17. package/dist/entry-tools/entry-tool-schema.js +4 -1
  18. package/dist/entry-tools/inspect-minecraft/internal.d.ts +235 -799
  19. package/dist/entry-tools/inspect-minecraft/internal.js +65 -28
  20. package/dist/entry-tools/inspect-minecraft-service.d.ts +372 -1736
  21. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  22. package/dist/entry-tools/manage-cache-service.d.ts +81 -91
  23. package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
  24. package/dist/entry-tools/validate-project-service.d.ts +164 -592
  25. package/dist/entry-tools/verify-mixin-target-service.d.ts +3 -19
  26. package/dist/era-classifier.d.ts +161 -0
  27. package/dist/era-classifier.js +292 -0
  28. package/dist/error-mapping.js +9 -2
  29. package/dist/index.d.ts +42 -4
  30. package/dist/index.js +637 -475
  31. package/dist/java-process.d.ts +2 -0
  32. package/dist/java-process.js +22 -2
  33. package/dist/json-rpc-framing.d.ts +77 -1
  34. package/dist/json-rpc-framing.js +249 -13
  35. package/dist/mapping/loaders/tiny-loom-selection.d.ts +88 -0
  36. package/dist/mapping/loaders/tiny-loom-selection.js +223 -0
  37. package/dist/mapping/loaders/tiny-loom.js +45 -33
  38. package/dist/mapping/loaders/tiny-maven.js +6 -11
  39. package/dist/mapping/parsers/tiny.d.ts +57 -0
  40. package/dist/mapping/parsers/tiny.js +99 -22
  41. package/dist/mapping-service.d.ts +19 -0
  42. package/dist/mapping-service.js +93 -9
  43. package/dist/mcp-helpers.d.ts +19 -2
  44. package/dist/mcp-helpers.js +48 -6
  45. package/dist/minecraft-explorer-service.d.ts +1 -1
  46. package/dist/mixin/types.d.ts +8 -0
  47. package/dist/mod-analyzer.js +7 -7
  48. package/dist/mod-decompile-service.js +1 -0
  49. package/dist/nbt/java-nbt-codec.js +12 -2
  50. package/dist/nbt/json-patch.js +14 -3
  51. package/dist/nbt/pipeline.js +40 -3
  52. package/dist/nbt/typed-json.js +26 -1
  53. package/dist/registration-adapter.d.ts +32 -0
  54. package/dist/registration-adapter.js +52 -0
  55. package/dist/request-context.d.ts +7 -0
  56. package/dist/request-context.js +9 -0
  57. package/dist/resources.d.ts +1 -1
  58. package/dist/resources.js +25 -19
  59. package/dist/server-identity.d.ts +27 -0
  60. package/dist/server-identity.js +26 -0
  61. package/dist/source/access-validate.js +53 -0
  62. package/dist/source/artifact-resolver.d.ts +69 -1
  63. package/dist/source/artifact-resolver.js +215 -14
  64. package/dist/source/class-source.d.ts +22 -0
  65. package/dist/source/class-source.js +162 -29
  66. package/dist/source/did-you-mean.d.ts +12 -1
  67. package/dist/source/did-you-mean.js +6 -2
  68. package/dist/source/file-access.js +150 -46
  69. package/dist/source/indexer.js +1 -0
  70. package/dist/source/nested-jars.d.ts +19 -0
  71. package/dist/source/nested-jars.js +90 -21
  72. package/dist/source/shared-utils.d.ts +21 -0
  73. package/dist/source/shared-utils.js +23 -0
  74. package/dist/source-service.d.ts +12 -0
  75. package/dist/source-service.js +3 -0
  76. package/dist/stdio-supervisor.d.ts +357 -2
  77. package/dist/stdio-supervisor.js +1031 -80
  78. package/dist/storage/db.d.ts +2 -1
  79. package/dist/storage/db.js +15 -8
  80. package/dist/synthetic-decorator.d.ts +24 -0
  81. package/dist/synthetic-decorator.js +48 -0
  82. package/dist/tool-contract-manifest.js +1 -1
  83. package/dist/tool-guidance.d.ts +17 -1
  84. package/dist/tool-guidance.js +417 -13
  85. package/dist/tool-schema-registry.d.ts +2 -0
  86. package/dist/tool-schema-registry.js +4 -0
  87. package/dist/tool-schemas.d.ts +2214 -3915
  88. package/dist/tool-schemas.js +34 -7
  89. package/dist/types.d.ts +35 -0
  90. package/dist/v1-parity-schemas.d.ts +7 -0
  91. package/dist/v1-parity-schemas.js +5584 -0
  92. package/dist/version-diff-service.d.ts +33 -0
  93. package/dist/version-diff-service.js +148 -3
  94. package/dist/version-service.js +36 -14
  95. package/dist/warning-details.js +18 -1
  96. package/docs/README-ja.md +9 -3
  97. package/docs/tool-reference.md +267 -22
  98. package/package.json +12 -9
@@ -8,9 +8,9 @@ import { buildClassSourceSnippet } from "./class-source/snippet-builder.js";
8
8
  import { remapAndCountMembers, sliceMembersWithLimit, projectMembersForWire, projectMembersByLevel } from "./class-source/members-builder.js";
9
9
  import { collectDidYouMeanCandidates } from "./did-you-mean.js";
10
10
  import { matchesMemberPattern } from "./member-pattern.js";
11
- import { resolveUniqueNestedJarForClass } from "./nested-jars.js";
11
+ import { findNestedJarClasses, resolveUniqueNestedJarForClass } from "./nested-jars.js";
12
12
  import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "../page-cursor.js";
13
- import { dedupeQualityFlags, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
13
+ import { dedupeQualityFlags, inheritArtifactMapping, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
14
14
  import { isUnobfuscatedVersion } from "../version-service.js";
15
15
  const MEMBERS_STATUS_LEGACY = process.env.MEMBERS_STATUS_LEGACY === "1";
16
16
  /**
@@ -82,6 +82,14 @@ function obfuscatedNamespaceHint(className) {
82
82
  function hasPartialNetMinecraftCoverage(qualityFlags) {
83
83
  return qualityFlags.includes("partial-source-no-net-minecraft");
84
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
+ }
85
93
  function classNameToClassPath(className) {
86
94
  const normalized = normalizePathStyle(className.trim()).replace(/\//g, ".");
87
95
  const segments = normalized.split(".").filter((segment) => segment.length > 0);
@@ -167,10 +175,26 @@ export function buildFallbackProvenance(svc, input) {
167
175
  transformChain
168
176
  };
169
177
  }
178
+ /**
179
+ * Near-miss candidates from the requested artifact's index followed by any the
180
+ * lookup's final artifact contributes, deduplicated by FQN so a class present in
181
+ * both is reported once, under the requested artifact.
182
+ */
183
+ function unionDidYouMeanCandidates(svc, requestedArtifactId, endedOnArtifactId, className) {
184
+ const requested = collectDidYouMeanCandidates(svc, requestedArtifactId, className);
185
+ if (endedOnArtifactId === requestedArtifactId) {
186
+ return requested;
187
+ }
188
+ const seen = new Set(requested.map((candidate) => candidate.className));
189
+ const fromFallback = collectDidYouMeanCandidates(svc, endedOnArtifactId, className, endedOnArtifactId).filter((candidate) => !seen.has(candidate.className));
190
+ return [...requested, ...fromFallback];
191
+ }
170
192
  export function buildClassSourceNotFoundError(svc, input) {
171
193
  const simpleName = input.className.split(/[.$]/).at(-1) ?? input.className;
194
+ const requestedArtifactId = input.requestedArtifactId ?? input.artifactId;
172
195
  const details = {
173
- artifactId: input.artifactId,
196
+ artifactId: requestedArtifactId,
197
+ ...(input.artifactId !== requestedArtifactId ? { fallbackArtifactId: input.artifactId } : {}),
174
198
  className: input.className,
175
199
  mapping: input.mappingApplied,
176
200
  qualityFlags: input.qualityFlags,
@@ -183,12 +207,18 @@ export function buildClassSourceNotFoundError(svc, input) {
183
207
  ...(input.nestedJars && input.nestedJars.length > 0 ? { nestedJars: input.nestedJars } : {}),
184
208
  // Candidates are hints from the symbol index, never assertions that the
185
209
  // class exists at the suggested location; empty when nothing usable.
186
- didYouMean: collectDidYouMeanCandidates(svc, input.artifactId, input.className)
210
+ //
211
+ // Both indexes are consulted, requested artifact first. Collecting from the
212
+ // requested artifact ALONE is empty by construction in the scenario the
213
+ // partial-source fallback exists to serve: that artifact is the one without
214
+ // net.minecraft symbols, which is why the fallback fired and indexed the other
215
+ // jar. Candidates from the artifact the caller did not name carry its id.
216
+ didYouMean: unionDidYouMeanCandidates(svc, requestedArtifactId, input.artifactId, input.className)
187
217
  };
188
218
  let nextAction = `Use find-class to resolve the correct fully-qualified name for "${simpleName}".`;
189
219
  let suggestionSpec = {
190
220
  tool: "find-class",
191
- params: { className: simpleName, artifactId: input.artifactId }
221
+ params: { className: simpleName, artifactId: requestedArtifactId }
192
222
  };
193
223
  if (input.targetKind === "version" && input.scope && input.scope !== "merged" && !input.projectPath) {
194
224
  nextAction +=
@@ -217,7 +247,7 @@ export function buildClassSourceNotFoundError(svc, input) {
217
247
  else {
218
248
  suggestionSpec = {
219
249
  tool: "find-class",
220
- params: { className: simpleName, artifactId: input.artifactId }
250
+ params: { className: simpleName, artifactId: requestedArtifactId }
221
251
  };
222
252
  }
223
253
  }
@@ -395,13 +425,14 @@ export function findClass(svc, input) {
395
425
  const filteredMatches = partialVanillaLookup && matches.every((match) => !match.qualifiedName.startsWith("net.minecraft.") && !match.qualifiedName.startsWith("com.mojang."))
396
426
  ? []
397
427
  : matches;
398
- if (filteredMatches.length === 0 && partialVanillaLookup) {
399
- 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.`);
400
- }
401
- if (filteredMatches.length === 0 && artifact.mappingApplied === "obfuscated" && looksLikeDeobfuscatedClassName(className)) {
402
- warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
403
- }
404
- return { matches: filteredMatches, total: filteredMatches.length, warnings };
428
+ return finishFindClass(svc, {
429
+ artifact,
430
+ artifactId,
431
+ className,
432
+ matches: filteredMatches,
433
+ partialVanillaLookup,
434
+ warnings
435
+ });
405
436
  }
406
437
  const result = svc.symbolsRepo.findScopedSymbols({
407
438
  artifactId,
@@ -410,32 +441,110 @@ export function findClass(svc, input) {
410
441
  symbolKinds: TYPE_SYMBOL_KINDS,
411
442
  limit: limit * 5
412
443
  });
413
- const matches = [];
444
+ // The extractor stores ONE qualifiedName per FILE (the top-level type), so a
445
+ // nested type's row carries its OUTER type's FQN. Reporting that verbatim
446
+ // returned a qualifiedName that did not contain the searched token at all —
447
+ // feeding match[0] into get-class-source then fetched the wrong class.
448
+ const candidates = [];
414
449
  for (const row of result.items) {
415
- if (matches.length >= limit)
416
- break;
417
450
  const isTypeSymbol = row.symbolKind === "class" || row.symbolKind === "interface" ||
418
451
  row.symbolKind === "enum" || row.symbolKind === "record";
419
452
  if (!isTypeSymbol)
420
453
  continue;
421
- matches.push({
422
- qualifiedName: row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", "."),
454
+ const enclosingQualifiedName = row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
455
+ const enclosingSimpleName = enclosingQualifiedName.split(".").at(-1) ?? enclosingQualifiedName;
456
+ const nested = enclosingSimpleName !== row.symbolName;
457
+ candidates.push({
458
+ qualifiedName: nested ? `${enclosingQualifiedName}.${row.symbolName}` : enclosingQualifiedName,
423
459
  filePath: row.filePath,
424
460
  line: row.line,
425
- symbolKind: row.symbolKind
461
+ symbolKind: row.symbolKind,
462
+ ...(nested ? { nested: true, enclosingClass: enclosingQualifiedName } : {})
426
463
  });
427
464
  }
465
+ // A top-level type named exactly like the query is what the caller almost
466
+ // always means; a nested type that merely shares the simple name comes after.
467
+ candidates.sort((left, right) => {
468
+ const leftNested = left.nested === true ? 1 : 0;
469
+ const rightNested = right.nested === true ? 1 : 0;
470
+ if (leftNested !== rightNested)
471
+ return leftNested - rightNested;
472
+ return left.qualifiedName.localeCompare(right.qualifiedName);
473
+ });
474
+ const matches = candidates.slice(0, limit);
428
475
  const partialVanillaLookup = hasPartialNetMinecraftCoverage(artifact.qualityFlags) && looksLikeDeobfuscatedClassName(className);
429
476
  const filteredMatches = partialVanillaLookup && matches.every((match) => !match.qualifiedName.startsWith("net.minecraft.") && !match.qualifiedName.startsWith("com.mojang."))
430
477
  ? []
431
478
  : matches;
432
- if (filteredMatches.length === 0 && partialVanillaLookup) {
479
+ return finishFindClass(svc, {
480
+ artifact,
481
+ artifactId,
482
+ className,
483
+ matches: filteredMatches,
484
+ partialVanillaLookup,
485
+ warnings
486
+ });
487
+ }
488
+ /**
489
+ * Shared tail for both findClass branches: coverage/namespace warnings plus a
490
+ * machine-usable recovery route.
491
+ *
492
+ * An empty result on an artifact whose index excludes net.minecraft is
493
+ * internally consistent but externally contradictory — get-class-source answers
494
+ * the SAME class from the binary fallback. The caller therefore gets both the
495
+ * reason and the exact call that works.
496
+ */
497
+ function finishFindClass(svc, input) {
498
+ const { artifact, artifactId, className, matches, partialVanillaLookup, warnings } = input;
499
+ let suggestedCall;
500
+ if (matches.length === 0 && partialVanillaLookup) {
433
501
  warnings.push(`Artifact source coverage is partial and excludes net.minecraft; returning non-vanilla matches for "${className}" would be misleading. Use get-class-source/get-class-members for binary fallback or get-class-api-matrix for mapped API inspection.`);
502
+ suggestedCall = buildSuggestedCall({
503
+ tool: "get-class-source",
504
+ params: {
505
+ className,
506
+ target: { kind: "artifact", artifactId },
507
+ mode: "metadata"
508
+ }
509
+ }).suggestedCall;
434
510
  }
435
- if (filteredMatches.length === 0 && artifact.mappingApplied === "obfuscated" && looksLikeDeobfuscatedClassName(className)) {
511
+ if (matches.length === 0 && shouldSuggestObfuscatedMapping(artifact, className)) {
436
512
  warnings.push(`No exact class symbol matched "${className}". ${obfuscatedNamespaceHint(className)}`);
437
513
  }
438
- return { matches: filteredMatches, total: filteredMatches.length, warnings };
514
+ return {
515
+ matches,
516
+ total: matches.length,
517
+ warnings,
518
+ ...(suggestedCall ? { suggestedCall } : {})
519
+ };
520
+ }
521
+ export async function findClassIncludingNested(svc, input) {
522
+ const indexed = findClass(svc, input);
523
+ if (indexed.total > 0) {
524
+ return indexed;
525
+ }
526
+ const artifact = svc.getArtifact(input.artifactId.trim());
527
+ const inventory = artifact.provenance?.nestedJars;
528
+ if (!artifact.qualityFlags.includes("shell-jar") ||
529
+ !artifact.binaryJarPath ||
530
+ !inventory ||
531
+ inventory.length === 0) {
532
+ return indexed;
533
+ }
534
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 200));
535
+ const matches = await findNestedJarClasses({
536
+ cacheDir: svc.config.cacheDir,
537
+ outerJarPath: artifact.binaryJarPath,
538
+ outerSignature: artifact.artifactId,
539
+ inventory,
540
+ className: input.className,
541
+ limit
542
+ });
543
+ return {
544
+ matches,
545
+ total: matches.length,
546
+ warnings: indexed.warnings
547
+ };
439
548
  }
440
549
  export async function getClassSource(svc, input) {
441
550
  const className = input.className.trim();
@@ -514,7 +623,7 @@ export async function getClassSource(svc, input) {
514
623
  const artifact = svc.getArtifact(artifactId);
515
624
  artifactId = artifact.artifactId;
516
625
  origin = artifact.origin;
517
- requestedMapping = input.mapping != null ? requestedMapping : (artifact.requestedMapping ?? requestedMapping);
626
+ requestedMapping = inheritArtifactMapping(input.mapping, artifact);
518
627
  mappingApplied = artifact.mappingApplied ?? requestedMapping;
519
628
  provenance = artifact.provenance;
520
629
  qualityFlags = artifact.qualityFlags;
@@ -559,7 +668,11 @@ export async function getClassSource(svc, input) {
559
668
  requestedMapping,
560
669
  mappingApplied,
561
670
  provenance: activeProvenance,
562
- qualityFlags: activeQualityFlags
671
+ qualityFlags: activeQualityFlags,
672
+ // A caller who declined decompilation must not pay for one here: the
673
+ // fallback used to hardcode allowDecompile:true and silently ran a full
674
+ // Vineflower pass on the binary jar.
675
+ allowDecompile: input.allowDecompile
563
676
  });
564
677
  if (!fallbackResolved || fallbackResolved.artifactId === activeArtifactId) {
565
678
  return false;
@@ -653,11 +766,15 @@ export async function getClassSource(svc, input) {
653
766
  if (!filePath) {
654
767
  throw buildClassSourceNotFoundError(svc, {
655
768
  artifactId: activeArtifactId,
769
+ requestedArtifactId: artifactId,
656
770
  className,
657
771
  lookupClassName: activeLookupClassName,
658
- mappingApplied: activeMappingApplied,
772
+ // The REQUESTED artifact's namespace and quality, matching the artifactId this
773
+ // error reports. The nested-jar redirect replaces the active values with the
774
+ // inner jar's, which would otherwise describe an artifact the caller never named.
775
+ mappingApplied,
659
776
  requestedMapping,
660
- qualityFlags: activeQualityFlags,
777
+ qualityFlags,
661
778
  attemptedBinaryFallback,
662
779
  targetKind: input.target?.kind,
663
780
  targetValue: input.target && "value" in input.target ? input.target.value : undefined,
@@ -692,11 +809,15 @@ export async function getClassSource(svc, input) {
692
809
  if (!row) {
693
810
  throw buildClassSourceNotFoundError(svc, {
694
811
  artifactId: activeArtifactId,
812
+ requestedArtifactId: artifactId,
695
813
  className,
696
814
  lookupClassName: activeLookupClassName,
697
- mappingApplied: activeMappingApplied,
815
+ // The REQUESTED artifact's namespace and quality, matching the artifactId this
816
+ // error reports. The nested-jar redirect replaces the active values with the
817
+ // inner jar's, which would otherwise describe an artifact the caller never named.
818
+ mappingApplied,
698
819
  requestedMapping,
699
- qualityFlags: activeQualityFlags,
820
+ qualityFlags,
700
821
  attemptedBinaryFallback,
701
822
  filePath,
702
823
  targetKind: input.target?.kind,
@@ -874,6 +995,7 @@ export async function getClassMembers(svc, input) {
874
995
  const artifact = svc.getArtifact(artifactId);
875
996
  artifactId = artifact.artifactId;
876
997
  origin = artifact.origin;
998
+ requestedMapping = inheritArtifactMapping(input.mapping, artifact);
877
999
  mappingApplied = artifact.mappingApplied ?? requestedMapping;
878
1000
  provenance = artifact.provenance;
879
1001
  qualityFlags = artifact.qualityFlags;
@@ -1143,7 +1265,18 @@ export async function getClassMembers(svc, input) {
1143
1265
  truncated,
1144
1266
  ...(nextCursor ? { nextCursor } : {}),
1145
1267
  ...(memberCursorIgnored ? { cursorIgnored: true } : {}),
1146
- context: signatureContext,
1268
+ context: {
1269
+ ...signatureContext,
1270
+ // The jar-derived context describes the BYTECODE the reader opened. The
1271
+ // members handed back have already been remapped into requestedMapping,
1272
+ // so echoing the jar namespace here contradicted `returnedNamespace` in
1273
+ // the same payload. Report what the response actually contains, and fill
1274
+ // the version the resolver established when the jar path yielded none.
1275
+ ...(signatureContext.minecraftVersion === "unknown" && version
1276
+ ? { minecraftVersion: version }
1277
+ : {}),
1278
+ mappingNamespace: requestedMapping
1279
+ },
1147
1280
  origin,
1148
1281
  artifactId,
1149
1282
  requestedMapping,
@@ -2,6 +2,13 @@ import type { SourceService } from "../source-service.js";
2
2
  export interface DidYouMeanCandidate {
3
3
  className: string;
4
4
  matchReason: string;
5
+ /**
6
+ * Set only when the candidate was found in an artifact OTHER than the one the caller
7
+ * named — today, the internal binary fallback or nested-jar redirect the lookup ended
8
+ * on. An unattributed candidate always means "in the artifact you asked about", so the
9
+ * field marks the exception rather than restating the common case on every entry.
10
+ */
11
+ artifactId?: string;
5
12
  }
6
13
  /**
7
14
  * Collects ranked near-miss class candidates from the artifact's symbol
@@ -10,5 +17,9 @@ export interface DidYouMeanCandidate {
10
17
  * edit-distance suggestions. Candidates are hints, never assertions that the
11
18
  * class exists at the suggested location. Returns an empty array when the
12
19
  * index has nothing usable.
20
+ *
21
+ * `attributeTo` stamps every produced candidate with that artifact id; pass it when
22
+ * collecting from an artifact the caller did not name, and leave it unset for the
23
+ * requested artifact.
13
24
  */
14
- export declare function collectDidYouMeanCandidates(svc: SourceService, artifactId: string, className: string): DidYouMeanCandidate[];
25
+ export declare function collectDidYouMeanCandidates(svc: SourceService, artifactId: string, className: string, attributeTo?: string): DidYouMeanCandidate[];
@@ -15,8 +15,12 @@ function fqnOfRow(row) {
15
15
  * edit-distance suggestions. Candidates are hints, never assertions that the
16
16
  * class exists at the suggested location. Returns an empty array when the
17
17
  * index has nothing usable.
18
+ *
19
+ * `attributeTo` stamps every produced candidate with that artifact id; pass it when
20
+ * collecting from an artifact the caller did not name, and leave it unset for the
21
+ * requested artifact.
18
22
  */
19
- export function collectDidYouMeanCandidates(svc, artifactId, className) {
23
+ export function collectDidYouMeanCandidates(svc, artifactId, className, attributeTo) {
20
24
  try {
21
25
  const simpleName = className.split(/[.$]/).at(-1) ?? className;
22
26
  if (!simpleName) {
@@ -29,7 +33,7 @@ export function collectDidYouMeanCandidates(svc, artifactId, className) {
29
33
  return;
30
34
  }
31
35
  seen.add(fqn);
32
- out.push({ className: fqn, matchReason });
36
+ out.push({ className: fqn, matchReason, ...(attributeTo ? { artifactId: attributeTo } : {}) });
33
37
  };
34
38
  const exact = svc.symbolsRepo.findScopedSymbols({
35
39
  artifactId,
@@ -1,13 +1,19 @@
1
1
  import { basename } from "node:path";
2
+ import { buildSuggestedCall } from "../build-suggested-call.js";
2
3
  import { ERROR_CODES, createError, isAppError } from "../errors.js";
3
4
  import { log } from "../logger.js";
4
5
  import { decodeJarEntryUtf8OrThrow, listJarEntries, readJarEntryCapped } from "../source-jar-reader.js";
5
6
  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/"];
7
+ // Read-through delivery for non-indexed jar resources: any entry of the
8
+ // backing jar is served directly when the source index has no row for it.
9
+ //
10
+ // Delivery used to be gated on an `assets/`+`data/` path prefix, which made the
11
+ // two entries an agent reaches for FIRST when inspecting a mod jar —
12
+ // `fabric.mod.json` at the archive root and `META-INF/MANIFEST.MF` — permanently
13
+ // unreachable (ERR_FILE_NOT_FOUND) even though `assets/**` read-through proved
14
+ // the mechanism worked. The prefix carries no safety value: the real guards are
15
+ // the traversal-shaped path rejection, the per-file byte cap, and the
16
+ // text/binary classification below, and all three apply to every entry alike.
11
17
  const READ_THROUGH_MAX_BYTES = 512 * 1024;
12
18
  const READ_THROUGH_TEXT_EXTENSIONS = new Set([
13
19
  ".json",
@@ -24,23 +30,104 @@ const READ_THROUGH_TEXT_EXTENSIONS = new Set([
24
30
  ".md",
25
31
  ".fsh",
26
32
  ".vsh",
27
- ".glsl"
33
+ ".glsl",
34
+ // Loader metadata that lives outside assets/ and data/.
35
+ ".mf",
36
+ ".accesswidener",
37
+ ".xml"
38
+ ]);
39
+ /**
40
+ * Extensions known to hold non-text payloads. Listing them keeps the common
41
+ * case a metadata-only probe: no bytes are read at all.
42
+ */
43
+ const READ_THROUGH_BINARY_EXTENSIONS = new Set([
44
+ ".class",
45
+ ".png",
46
+ ".jpg",
47
+ ".jpeg",
48
+ ".gif",
49
+ ".bmp",
50
+ ".webp",
51
+ ".ico",
52
+ ".jar",
53
+ ".zip",
54
+ ".gz",
55
+ ".tar",
56
+ ".ogg",
57
+ ".wav",
58
+ ".mp3",
59
+ ".nbt",
60
+ ".dat",
61
+ ".bin",
62
+ ".so",
63
+ ".dll",
64
+ ".dylib",
65
+ ".ttf",
66
+ ".otf",
67
+ ".woff",
68
+ ".woff2",
69
+ ".pack",
70
+ ".mca",
71
+ ".rsa",
72
+ ".dsa"
28
73
  ]);
29
74
  const NEARBY_PATH_HINT_LIMIT = 5;
75
+ /** Ratio of C0 control bytes above which a sniffed entry counts as binary. */
76
+ const SNIFF_MAX_CONTROL_RATIO = 0.05;
30
77
  function isTraversalShapedPath(filePath) {
31
78
  return (filePath.startsWith("/") ||
32
79
  filePath.includes("\u0000") ||
33
80
  filePath.split(/[\\/]/).includes(".."));
34
81
  }
35
- function hasReadThroughPrefix(filePath) {
36
- return READ_THROUGH_PREFIXES.some((prefix) => filePath.startsWith(prefix));
37
- }
38
- function readThroughTextExtension(filePath) {
82
+ function classifyReadThroughEntry(filePath) {
39
83
  const dot = filePath.lastIndexOf(".");
40
- if (dot < 0) {
41
- return false;
84
+ const slash = filePath.lastIndexOf("/");
85
+ if (dot < 0 || dot < slash) {
86
+ return "unknown";
87
+ }
88
+ const extension = filePath.slice(dot).toLowerCase();
89
+ if (READ_THROUGH_TEXT_EXTENSIONS.has(extension)) {
90
+ return "text";
91
+ }
92
+ if (READ_THROUGH_BINARY_EXTENSIONS.has(extension)) {
93
+ return "binary";
94
+ }
95
+ return "unknown";
96
+ }
97
+ /**
98
+ * Decodes a capped buffer when it really is UTF-8 text, else reports binary.
99
+ * A NUL byte, a strict-UTF-8 decode failure, or a high C0 control-byte ratio
100
+ * all mean "do not hand this back as text".
101
+ */
102
+ function sniffUtf8Text(buffer) {
103
+ if (buffer.length === 0) {
104
+ return "";
105
+ }
106
+ if (buffer.includes(0)) {
107
+ return undefined;
108
+ }
109
+ let decoded;
110
+ try {
111
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ let control = 0;
117
+ for (const byte of buffer) {
118
+ if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
119
+ control += 1;
120
+ }
121
+ }
122
+ return control / buffer.length > SNIFF_MAX_CONTROL_RATIO ? undefined : decoded;
123
+ }
124
+ /** Trims a capped buffer back to a UTF-8 character boundary at `limit`. */
125
+ function trimToUtf8Boundary(buffer, limit) {
126
+ let cut = Math.min(limit, buffer.length);
127
+ while (cut > 0 && ((buffer[cut] ?? 0) & 0xc0) === 0x80) {
128
+ cut -= 1;
42
129
  }
43
- return READ_THROUGH_TEXT_EXTENSIONS.has(filePath.slice(dot).toLowerCase());
130
+ return buffer.subarray(0, cut);
44
131
  }
45
132
  /**
46
133
  * Same-basename entries elsewhere in the jar, for not-found hints. Covers
@@ -52,7 +139,7 @@ async function collectNearbyPaths(binaryJarPath, missingPath) {
52
139
  const wanted = basename(missingPath);
53
140
  const entries = await listJarEntries(binaryJarPath);
54
141
  return entries
55
- .filter((entry) => hasReadThroughPrefix(entry) && basename(entry) === wanted)
142
+ .filter((entry) => basename(entry) === wanted)
56
143
  .slice(0, NEARBY_PATH_HINT_LIMIT);
57
144
  }
58
145
  catch {
@@ -95,7 +182,7 @@ export async function getArtifactFile(svc, input) {
95
182
  const artifact = svc.getArtifact(input.artifactId);
96
183
  const normalizedPath = normalizePathStyle(input.filePath);
97
184
  const row = svc.filesRepo.getFileContent(artifact.artifactId, normalizedPath);
98
- if (!row && hasReadThroughPrefix(normalizedPath) && artifact.binaryJarPath) {
185
+ if (!row && artifact.binaryJarPath) {
99
186
  return await readFileThroughJar(svc, {
100
187
  artifact,
101
188
  binaryJarPath: artifact.binaryJarPath,
@@ -108,7 +195,15 @@ export async function getArtifactFile(svc, input) {
108
195
  throw createError({
109
196
  code: ERROR_CODES.FILE_NOT_FOUND,
110
197
  message: `Source file "${input.filePath}" was not found.`,
111
- details: { artifactId: input.artifactId, filePath: input.filePath }
198
+ details: {
199
+ artifactId: input.artifactId,
200
+ filePath: input.filePath,
201
+ nextAction: "This artifact has no backing binary jar, so only indexed source files are reachable. List what is indexed with list-artifact-files.",
202
+ ...buildSuggestedCall({
203
+ tool: "list-artifact-files",
204
+ params: { artifactId: input.artifactId, limit: 50 }
205
+ })
206
+ }
112
207
  });
113
208
  }
114
209
  const maxBytes = clampLimit(input.maxBytes, svc.config.maxContentBytes, Number.MAX_SAFE_INTEGER);
@@ -145,16 +240,16 @@ export async function getArtifactFile(svc, input) {
145
240
  }
146
241
  async function readFileThroughJar(svc, args) {
147
242
  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;
243
+ const kind = classifyReadThroughEntry(filePath);
244
+ const cap = kind === "binary"
245
+ ? 0
246
+ : Math.min(clampLimit(args.maxBytes, svc.config.maxContentBytes, Number.MAX_SAFE_INTEGER), READ_THROUGH_MAX_BYTES);
152
247
  let capped;
153
248
  try {
154
249
  // 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);
250
+ // oversized entry is never fully materialized in memory. Known-binary
251
+ // entries are metadata-only probes (no content read at all).
252
+ capped = await readJarEntryCapped(binaryJarPath, filePath, kind === "binary" ? 0 : cap + 4);
158
253
  }
159
254
  catch (error) {
160
255
  if (!isAppError(error) || error.code !== ERROR_CODES.SOURCE_NOT_FOUND) {
@@ -190,30 +285,37 @@ async function readFileThroughJar(svc, args) {
190
285
  }),
191
286
  deliveryMode: "jar-read-through"
192
287
  };
193
- if (!isText) {
288
+ const omitBinary = (reason) => ({
289
+ ...base,
290
+ content: "",
291
+ contentBytes: capped.entrySize,
292
+ truncated: false,
293
+ contentOmittedReason: reason
294
+ });
295
+ if (kind === "binary") {
296
+ return omitBinary("Entry is not a known text format; binary content is not delivered. Size and existence are reported instead.");
297
+ }
298
+ const truncated = capped.entrySize > cap;
299
+ // Trim the capped prefix back to a UTF-8 character boundary before decoding;
300
+ // the tail past the cap is dropped by design.
301
+ const trimmed = truncated ? trimToUtf8Boundary(capped.buffer, cap) : capped.buffer;
302
+ if (kind === "unknown") {
303
+ // No extension to go on: decide from the bytes rather than refuse a text
304
+ // entry (META-INF/services/*, LICENSE files) on the strength of its name.
305
+ const sniffed = sniffUtf8Text(trimmed);
306
+ if (sniffed === undefined) {
307
+ return omitBinary("Entry has no known text extension and its bytes are not UTF-8 text; binary content is not delivered. Size and existence are reported instead.");
308
+ }
194
309
  return {
195
310
  ...base,
196
- content: "",
311
+ content: sniffed,
197
312
  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."
313
+ truncated
200
314
  };
201
315
  }
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
- }
316
+ const content = truncated
317
+ ? trimmed.toString("utf8")
318
+ : decodeJarEntryUtf8OrThrow(capped.buffer, binaryJarPath, filePath);
217
319
  return {
218
320
  ...base,
219
321
  content,
@@ -234,10 +336,12 @@ export async function listArtifactFiles(svc, input) {
234
336
  prefix
235
337
  });
236
338
  const normalizedPrefix = normalizeOptionalString(prefix);
237
- if (normalizedPrefix &&
238
- page.items.length === 0 &&
239
- (normalizedPrefix.startsWith("assets/") || normalizedPrefix.startsWith("data/"))) {
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).");
339
+ // A prefix that matched nothing is the exact moment read-through is worth
340
+ // naming: the index holds Java source only, while EVERY jar entry —
341
+ // archive-root files such as fabric.mod.json and META-INF/** included — is
342
+ // reachable by exact path.
343
+ if (normalizedPrefix && page.items.length === 0 && artifact.binaryJarPath) {
344
+ warnings.push("Indexed artifacts currently include Java source only; non-Java resources are not indexed. Any text entry of the backing jar — including archive-root files such as fabric.mod.json and META-INF/** — is served directly by exact path with get-artifact-file (read-through delivery).");
241
345
  }
242
346
  return {
243
347
  items: page.items,
@@ -252,6 +252,7 @@ export async function buildRebuiltArtifactData(svc, resolved) {
252
252
  vineflowerJarPath: vineflowerPath,
253
253
  artifactIdCandidate: resolved.artifactId,
254
254
  timeoutMs: 120_000,
255
+ maxMemoryMb: svc.config.decompileMaxMemoryMb,
255
256
  signature: resolved.artifactId
256
257
  });
257
258
  files = decompileResult.javaFiles.map((entry) => ({
@@ -9,6 +9,25 @@ export interface NestedJarMatch {
9
9
  entryName: string;
10
10
  extractedPath: string;
11
11
  }
12
+ export interface NestedClassMatch {
13
+ qualifiedName: string;
14
+ filePath: string;
15
+ line: number;
16
+ symbolKind: "class";
17
+ }
18
+ /**
19
+ * Finds exact class-name matches across a shell's nested bytecode inventory.
20
+ * Results use the same binary-backed class representation as entry-tool search:
21
+ * an inferred outer Java path, line 1, and the broad class symbol kind.
22
+ */
23
+ export declare function findNestedJarClasses(args: {
24
+ cacheDir: string;
25
+ outerJarPath: string;
26
+ outerSignature: string;
27
+ inventory: string[];
28
+ className: string;
29
+ limit: number;
30
+ }): Promise<NestedClassMatch[]>;
12
31
  /**
13
32
  * Shell detection with both required signals: near-zero own classes AND
14
33
  * bundled nested jars (META-INF/jars scan or fabric.mod.json "jars"