@adhisang/minecraft-modding-mcp 6.1.1 → 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 (53) hide show
  1. package/CHANGELOG.md +30 -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/source/artifact-resolver.d.ts +2 -0
  25. package/dist/source/artifact-resolver.js +24 -1
  26. package/dist/source/class-source/members-builder.d.ts +4 -0
  27. package/dist/source/class-source/members-builder.js +3 -1
  28. package/dist/source/class-source.d.ts +2 -1
  29. package/dist/source/class-source.js +154 -17
  30. package/dist/source/did-you-mean.d.ts +14 -0
  31. package/dist/source/did-you-mean.js +79 -0
  32. package/dist/source/file-access.js +159 -3
  33. package/dist/source/indexer.js +72 -2
  34. package/dist/source/lifecycle/runtime-check.js +9 -5
  35. package/dist/source/nested-jars.d.ts +59 -0
  36. package/dist/source/nested-jars.js +198 -0
  37. package/dist/source/workspace-target.js +5 -2
  38. package/dist/source-jar-reader.d.ts +16 -0
  39. package/dist/source-jar-reader.js +82 -0
  40. package/dist/source-service.d.ts +36 -0
  41. package/dist/source-service.js +49 -6
  42. package/dist/stage-emitter.js +24 -8
  43. package/dist/stdio-supervisor.d.ts +92 -9
  44. package/dist/stdio-supervisor.js +915 -103
  45. package/dist/tool-contract-manifest.js +1 -1
  46. package/dist/tool-guidance.js +3 -1
  47. package/dist/tool-schemas.d.ts +1337 -149
  48. package/dist/tool-schemas.js +38 -7
  49. package/dist/types.d.ts +23 -0
  50. package/dist/workspace-mapping-service.d.ts +1 -0
  51. package/dist/workspace-mapping-service.js +120 -8
  52. package/docs/tool-reference.md +19 -3
  53. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { decompileBinaryJar } from "../decompiler/vineflower.js";
7
7
  import { ERROR_CODES, createError, isAppError } from "../errors.js";
8
8
  import { log } from "../logger.js";
9
9
  import { resolveMojangTinyFile } from "../mojang-tiny-mapping-service.js";
10
+ import { detectShellJarInventory } from "./nested-jars.js";
10
11
  import { iterateJavaEntriesAsUtf8 } from "../source-jar-reader.js";
11
12
  import { extractSymbolsFromSource } from "../symbols/symbol-extractor.js";
12
13
  import { remapJar } from "../tiny-remapper-service.js";
@@ -44,7 +45,11 @@ export async function indexArtifact(svc, input) {
44
45
  const artifact = svc.getArtifact(artifactId);
45
46
  const force = input.force ?? false;
46
47
  const meta = svc.indexMetaRepo.get(artifact.artifactId);
47
- const hasFiles = meta ? meta.filesCount > 0 : false;
48
+ // Shell jars legitimately index zero files; without force they count as
49
+ // current instead of re-running shell detection on every reindex call.
50
+ const hasFiles = meta
51
+ ? meta.filesCount > 0 || artifact.qualityFlags.includes("shell-jar")
52
+ : false;
48
53
  const expectedSignature = artifact.artifactSignature ?? fallbackArtifactSignature(artifact.artifactId);
49
54
  const reason = resolveIndexRebuildReason({
50
55
  force,
@@ -180,8 +185,56 @@ export async function buildRebuiltArtifactData(svc, resolved) {
180
185
  let files = [];
181
186
  if (resolved.sourceJarPath) {
182
187
  files = await loadFromSourceJar(svc, resolved.sourceJarPath);
188
+ // Loom split-source pairs (common/clientOnly) publish the version across
189
+ // two sources jars; index the companion half too so neither side's
190
+ // classes go missing. The primary jar wins on duplicate paths. Note the
191
+ // artifact signature derives from the primary jar only: a regenerated
192
+ // companion lands at a new hash-addressed path, so persisted provenance
193
+ // can point at a deleted companion — that must degrade to a primary-only
194
+ // index, never fail the primary rebuild.
195
+ for (const companion of resolved.provenance?.companionSourceJars ?? []) {
196
+ let extra;
197
+ try {
198
+ extra = await loadFromSourceJar(svc, companion);
199
+ }
200
+ catch (companionError) {
201
+ log("warn", "index.companion_source_skipped", {
202
+ artifactId: resolved.artifactId,
203
+ companion,
204
+ reason: companionError instanceof Error ? companionError.message : String(companionError)
205
+ });
206
+ continue;
207
+ }
208
+ const seenPaths = new Set(files.map((file) => file.filePath));
209
+ files.push(...extra.filter((file) => !seenPaths.has(file.filePath)));
210
+ }
183
211
  }
184
212
  else if (resolved.binaryJarPath) {
213
+ // Jar-in-Jar shells (near-zero own classes, all content in nested jars)
214
+ // would decompile to zero Java files and dead-end in
215
+ // ERR_DECOMPILER_FAILED. Detect them before remap/decompile: the artifact
216
+ // is created with an empty file index, the nested-jar inventory persisted
217
+ // in provenance, and class-family lookups redirect into the nested jars.
218
+ const shellInventory = await detectShellJarInventory(resolved.binaryJarPath);
219
+ if (shellInventory) {
220
+ const qualityFlags = resolved.qualityFlags ?? [];
221
+ resolved.qualityFlags = qualityFlags.includes("shell-jar")
222
+ ? qualityFlags
223
+ : [...qualityFlags, "shell-jar"];
224
+ if (resolved.provenance) {
225
+ // Mutate in place: resolveArtifact holds a reference to this object
226
+ // for its response, mirroring the binaryJarPath swap below.
227
+ resolved.provenance.nestedJars = shellInventory;
228
+ }
229
+ resolved.isDecompiled = false;
230
+ return {
231
+ files: [],
232
+ symbols: [],
233
+ totalContentBytes: 0,
234
+ indexedAt: new Date().toISOString(),
235
+ indexDurationMs: Date.now() - indexStartedAt
236
+ };
237
+ }
185
238
  const decompileInputJarPath = await maybeRemapBinaryForMojang(svc, resolved);
186
239
  // When the binary jar was remapped from obfuscated to mojang, swap the resolved
187
240
  // artifact's binaryJarPath to the remapped jar so downstream bytecode consumers
@@ -298,7 +351,11 @@ export async function ingestIfNeeded(svc, resolved) {
298
351
  // Derive hasFiles from meta instead of a separate listFiles probe: when meta is
299
352
  // absent the reason is "missing_meta" regardless of hasFiles, and when present
300
353
  // meta.filesCount is the authoritative count written alongside the file rows.
301
- const hasFiles = meta ? meta.filesCount > 0 : false;
354
+ // Shell jars legitimately index zero files (their content lives in nested
355
+ // jars), so their empty index counts as current instead of forcing a
356
+ // re-detection rebuild on every warm resolve.
357
+ const existingIsShell = existing?.qualityFlags.includes("shell-jar") ?? false;
358
+ const hasFiles = meta ? meta.filesCount > 0 || existingIsShell : false;
302
359
  const reason = resolveIndexRebuildReason({
303
360
  force: false,
304
361
  expectedSignature: resolved.artifactSignature,
@@ -306,6 +363,19 @@ export async function ingestIfNeeded(svc, resolved) {
306
363
  meta
307
364
  });
308
365
  if (existing && reason === "already_current") {
366
+ if (existingIsShell) {
367
+ // Reconcile shell state onto the freshly-resolved object so warm-cache
368
+ // responses carry the same flag and inventory as the first resolve.
369
+ const qualityFlags = resolved.qualityFlags ?? [];
370
+ if (!qualityFlags.includes("shell-jar")) {
371
+ resolved.qualityFlags = [...qualityFlags, "shell-jar"];
372
+ }
373
+ const persistedInventory = existing.provenance?.nestedJars;
374
+ if (resolved.provenance && persistedInventory && !resolved.provenance.nestedJars) {
375
+ resolved.provenance.nestedJars = persistedInventory;
376
+ }
377
+ resolved.isDecompiled = false;
378
+ }
309
379
  // Mojang binary-remap reconciliation on the warm cache hit path:
310
380
  // resolveSourceTargetInternal always returns the original binary jar
311
381
  // (resolver does not know about prior remap output), so without this
@@ -73,13 +73,16 @@ export async function checkSymbolExistsInUnobfuscatedRuntime(svc, input, fallbac
73
73
  ]
74
74
  };
75
75
  }
76
- const warnings = [
77
- ...fallbackBase.warnings,
78
- ...signature.warnings,
79
- `Version ${version} is unobfuscated; validated symbol existence against runtime bytecode.`
80
- ];
76
+ const warnings = [...fallbackBase.warnings, ...signature.warnings];
77
+ // Runtime validation is reported as the structured
78
+ // mappingContext.runtimeValidated flag instead of a per-response sentence.
79
+ const runtimeValidatedContext = {
80
+ ...fallbackBase.mappingContext,
81
+ runtimeValidated: true
82
+ };
81
83
  const buildResolved = (resolvedSymbol) => ({
82
84
  ...fallbackBase,
85
+ mappingContext: runtimeValidatedContext,
83
86
  querySymbol,
84
87
  resolved: true,
85
88
  status: "resolved",
@@ -96,6 +99,7 @@ export async function checkSymbolExistsInUnobfuscatedRuntime(svc, input, fallbac
96
99
  });
97
100
  const buildUnresolved = (status) => ({
98
101
  ...fallbackBase,
102
+ mappingContext: runtimeValidatedContext,
99
103
  querySymbol,
100
104
  resolved: false,
101
105
  status,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A jar with at most this many own `.class` entries can qualify as a
3
+ * Jar-in-Jar shell. Real Fabric API umbrella jars carry zero to a handful of
4
+ * marker classes while every API class lives in META-INF/jars.
5
+ */
6
+ export declare const SHELL_JAR_MAX_OUTER_CLASSES = 8;
7
+ export declare const NESTED_JAR_CACHE_DIRNAME = "nested-jars";
8
+ export interface NestedJarMatch {
9
+ entryName: string;
10
+ extractedPath: string;
11
+ }
12
+ /**
13
+ * Shell detection with both required signals: near-zero own classes AND
14
+ * bundled nested jars (META-INF/jars scan or fabric.mod.json "jars"
15
+ * declarations that really exist in the archive). Returns the inventory for
16
+ * shells and undefined for every regular jar.
17
+ */
18
+ export declare function detectShellJarInventory(jarPath: string): Promise<string[] | undefined>;
19
+ /**
20
+ * Content-addressed on-disk location of an extracted nested jar. The digest
21
+ * covers the outer jar path, its signature, and the entry name, so the same
22
+ * shell always maps to the same extracted file and re-resolution reuses it.
23
+ */
24
+ export declare function nestedJarCachePath(cacheDir: string, outerJarPath: string, outerSignature: string, entryName: string): string;
25
+ /**
26
+ * Extracts one nested jar to the content-addressed cache (no-op when already
27
+ * present). Entry-name safety is enforced by readJarEntryAsBuffer, and the
28
+ * on-disk name is the digest — never derived from the entry name — so a
29
+ * hostile entry name cannot escape the cache directory.
30
+ */
31
+ export declare function extractNestedJar(cacheDir: string, outerJarPath: string, outerSignature: string, entryName: string): Promise<string>;
32
+ /**
33
+ * Finds every nested jar of a shell that contains the class. Zero matches
34
+ * means the class genuinely is not bundled; more than one means the caller
35
+ * must return candidates instead of picking silently.
36
+ *
37
+ * The lookup is deliberately single-level: it inspects each nested jar's own
38
+ * class list only. A nested jar that is itself a shell surfaces its content
39
+ * when resolved directly as its own artifact.
40
+ */
41
+ export declare function findNestedJarsContainingClass(args: {
42
+ cacheDir: string;
43
+ outerJarPath: string;
44
+ outerSignature: string;
45
+ inventory: string[];
46
+ internalName: string;
47
+ }): Promise<NestedJarMatch[]>;
48
+ /**
49
+ * Resolves the single nested jar containing a class. Zero matches returns
50
+ * undefined (the caller keeps its not-found contract); several matches throw
51
+ * candidates instead of picking one silently.
52
+ */
53
+ export declare function resolveUniqueNestedJarForClass(args: {
54
+ cacheDir: string;
55
+ outerJarPath: string;
56
+ outerArtifactId: string;
57
+ inventory: string[];
58
+ className: string;
59
+ }): Promise<NestedJarMatch | undefined>;
@@ -0,0 +1,198 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { access, mkdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { buildSuggestedCall } from "../build-suggested-call.js";
5
+ import { ERROR_CODES, createError } from "../errors.js";
6
+ import { collectNestedJars } from "../mod-analyzer.js";
7
+ import { listJarEntries, readJarEntryAsBuffer, readJarEntryAsUtf8 } from "../source-jar-reader.js";
8
+ /**
9
+ * A jar with at most this many own `.class` entries can qualify as a
10
+ * Jar-in-Jar shell. Real Fabric API umbrella jars carry zero to a handful of
11
+ * marker classes while every API class lives in META-INF/jars.
12
+ */
13
+ export const SHELL_JAR_MAX_OUTER_CLASSES = 8;
14
+ export const NESTED_JAR_CACHE_DIRNAME = "nested-jars";
15
+ // In-process cache of class listings per extracted nested jar, so repeated
16
+ // class lookups against the same shell never re-open its nested jars. The
17
+ // shell's own inventory is persisted with the artifact record; this cache is
18
+ // only the per-nested-jar class membership.
19
+ const classSetCache = new Map();
20
+ const CLASS_SET_CACHE_MAX = 32;
21
+ function rememberClassSet(key, value) {
22
+ classSetCache.delete(key);
23
+ classSetCache.set(key, value);
24
+ while (classSetCache.size > CLASS_SET_CACHE_MAX) {
25
+ const oldest = classSetCache.keys().next().value;
26
+ if (oldest === undefined) {
27
+ break;
28
+ }
29
+ classSetCache.delete(oldest);
30
+ }
31
+ }
32
+ /**
33
+ * Shell detection with both required signals: near-zero own classes AND
34
+ * bundled nested jars (META-INF/jars scan or fabric.mod.json "jars"
35
+ * declarations that really exist in the archive). Returns the inventory for
36
+ * shells and undefined for every regular jar.
37
+ */
38
+ export async function detectShellJarInventory(jarPath) {
39
+ let entries;
40
+ try {
41
+ entries = await listJarEntries(jarPath);
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ let ownClassCount = 0;
47
+ for (const entry of entries) {
48
+ if (entry.endsWith(".class")) {
49
+ ownClassCount += 1;
50
+ if (ownClassCount > SHELL_JAR_MAX_OUTER_CLASSES) {
51
+ return undefined;
52
+ }
53
+ }
54
+ }
55
+ let declared;
56
+ if (entries.includes("fabric.mod.json")) {
57
+ try {
58
+ const parsed = JSON.parse(await readJarEntryAsUtf8(jarPath, "fabric.mod.json"));
59
+ const jars = parsed?.jars;
60
+ if (Array.isArray(jars)) {
61
+ declared = jars
62
+ .map((entry) => entry?.file)
63
+ .filter((file) => typeof file === "string");
64
+ }
65
+ }
66
+ catch {
67
+ // Unreadable metadata leaves only the META-INF/jars scan signal.
68
+ }
69
+ }
70
+ const inventory = collectNestedJars(entries, declared);
71
+ return inventory.length > 0 ? inventory : undefined;
72
+ }
73
+ /**
74
+ * Content-addressed on-disk location of an extracted nested jar. The digest
75
+ * covers the outer jar path, its signature, and the entry name, so the same
76
+ * shell always maps to the same extracted file and re-resolution reuses it.
77
+ */
78
+ export function nestedJarCachePath(cacheDir, outerJarPath, outerSignature, entryName) {
79
+ const digest = createHash("sha256")
80
+ .update(`${outerJarPath}|${outerSignature}|${entryName}`)
81
+ .digest("hex");
82
+ return join(cacheDir, NESTED_JAR_CACHE_DIRNAME, `${digest}.jar`);
83
+ }
84
+ /**
85
+ * Extracts one nested jar to the content-addressed cache (no-op when already
86
+ * present). Entry-name safety is enforced by readJarEntryAsBuffer, and the
87
+ * on-disk name is the digest — never derived from the entry name — so a
88
+ * hostile entry name cannot escape the cache directory.
89
+ */
90
+ export async function extractNestedJar(cacheDir, outerJarPath, outerSignature, entryName) {
91
+ const finalPath = nestedJarCachePath(cacheDir, outerJarPath, outerSignature, entryName);
92
+ try {
93
+ await access(finalPath);
94
+ return finalPath;
95
+ }
96
+ catch {
97
+ // fall through to extraction
98
+ }
99
+ const bytes = await readJarEntryAsBuffer(outerJarPath, entryName);
100
+ await mkdir(dirname(finalPath), { recursive: true });
101
+ const tempPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}`;
102
+ await writeFile(tempPath, bytes);
103
+ try {
104
+ await rename(tempPath, finalPath);
105
+ }
106
+ catch (renameError) {
107
+ // A concurrent extraction of the same entry may have won the rename.
108
+ // The content-addressed final file being present makes this call a
109
+ // success; anything else is a real failure.
110
+ try {
111
+ await access(finalPath);
112
+ }
113
+ catch {
114
+ throw renameError;
115
+ }
116
+ await rm(tempPath, { force: true });
117
+ }
118
+ return finalPath;
119
+ }
120
+ /**
121
+ * Finds every nested jar of a shell that contains the class. Zero matches
122
+ * means the class genuinely is not bundled; more than one means the caller
123
+ * must return candidates instead of picking silently.
124
+ *
125
+ * The lookup is deliberately single-level: it inspects each nested jar's own
126
+ * class list only. A nested jar that is itself a shell surfaces its content
127
+ * when resolved directly as its own artifact.
128
+ */
129
+ export async function findNestedJarsContainingClass(args) {
130
+ const classEntry = `${args.internalName}.class`;
131
+ const matches = [];
132
+ for (const entryName of args.inventory) {
133
+ let extractedPath;
134
+ try {
135
+ extractedPath = await extractNestedJar(args.cacheDir, args.outerJarPath, args.outerSignature, entryName);
136
+ }
137
+ catch {
138
+ // Unsafe or unreadable nested entries never become candidates.
139
+ continue;
140
+ }
141
+ let classSet = classSetCache.get(extractedPath);
142
+ if (!classSet) {
143
+ try {
144
+ const entries = await listJarEntries(extractedPath);
145
+ classSet = new Set(entries.filter((entry) => entry.endsWith(".class")));
146
+ }
147
+ catch {
148
+ continue;
149
+ }
150
+ rememberClassSet(extractedPath, classSet);
151
+ }
152
+ if (classSet.has(classEntry)) {
153
+ matches.push({ entryName, extractedPath });
154
+ }
155
+ }
156
+ return matches;
157
+ }
158
+ /**
159
+ * Resolves the single nested jar containing a class. Zero matches returns
160
+ * undefined (the caller keeps its not-found contract); several matches throw
161
+ * candidates instead of picking one silently.
162
+ */
163
+ export async function resolveUniqueNestedJarForClass(args) {
164
+ const internalName = args.className.replace(/\./g, "/");
165
+ const matches = await findNestedJarsContainingClass({
166
+ cacheDir: args.cacheDir,
167
+ outerJarPath: args.outerJarPath,
168
+ outerSignature: args.outerArtifactId,
169
+ inventory: args.inventory,
170
+ internalName
171
+ });
172
+ if (matches.length === 0) {
173
+ return undefined;
174
+ }
175
+ const single = matches.length === 1 ? matches[0] : undefined;
176
+ if (single) {
177
+ return single;
178
+ }
179
+ throw createError({
180
+ code: ERROR_CODES.NESTED_JAR_AMBIGUOUS,
181
+ message: `Class "${args.className}" exists in ${matches.length} nested jars bundled by this shell jar; refusing to pick one automatically.`,
182
+ details: {
183
+ className: args.className,
184
+ shellArtifactId: args.outerArtifactId,
185
+ nestedJarCandidates: matches.map((match) => match.entryName),
186
+ nextAction: "Resolve the intended nested jar as its own artifact, then query the class against that artifactId.",
187
+ ...buildSuggestedCall({
188
+ tool: "resolve-artifact",
189
+ params: undefined,
190
+ examples: matches.map((match) => ({
191
+ params: { target: { kind: "jar", value: match.extractedPath } },
192
+ reason: `Query classes inside "${match.entryName}" directly.`
193
+ }))
194
+ })
195
+ }
196
+ });
197
+ }
198
+ //# sourceMappingURL=nested-jars.js.map
@@ -243,7 +243,7 @@ export async function synthesizeDependencyTarget(svc, input, dep) {
243
243
  ? `Multiple cached versions for ${group}:${name} in ~/.gradle/caches/modules-2 (${result.candidatesSeen.join(", ")}); refusing to pick without project-specific evidence.`
244
244
  : `Could not resolve a version for dependency ${group}:${name} from gradle.properties or modules-2 cache.`;
245
245
  const nextAction = ambiguous
246
- ? `Set ${name}_version (or another supported gradle.properties key) so the project's intended version is unambiguous, or pass an explicit version on the dependency target.`
246
+ ? `Set ${name}_version (or another supported gradle.properties key) so the project's intended version is unambiguous, pass an explicit version on the dependency target, or declare the umbrella version property so the cached umbrella POM can supply the submodule version.`
247
247
  : "Provide an explicit version on the dependency target, or add a property to gradle.properties so detectDependencyVersion can find it.";
248
248
  throw createError({
249
249
  code: ERROR_CODES.DEPENDENCY_VERSION_UNRESOLVED,
@@ -305,7 +305,10 @@ export async function synthesizeDependencyTarget(svc, input, dep) {
305
305
  source: result.source,
306
306
  candidatesSeen: result.candidatesSeen,
307
307
  attempts: result.attempts,
308
- cacheHit: false
308
+ cacheHit: false,
309
+ ...(result.submoduleVersionSource
310
+ ? { submoduleVersionSource: result.submoduleVersionSource }
311
+ : {})
309
312
  }
310
313
  };
311
314
  }
@@ -3,6 +3,7 @@ export declare function __getZipOpenCount(): number;
3
3
  export declare function __resetZipOpenCount(): void;
4
4
  interface ZipEntry {
5
5
  fileName: string;
6
+ uncompressedSize: number;
6
7
  }
7
8
  export interface ZipFile {
8
9
  readEntry(): void;
@@ -38,6 +39,21 @@ export declare function listJavaEntries(jarPath: string): Promise<string[]>;
38
39
  export declare function hasAnyJarEntry(jarPath: string, predicate: (entryPath: string) => boolean): Promise<boolean>;
39
40
  export declare function readJarEntryAsUtf8(jarPath: string, entryPath: string): Promise<string>;
40
41
  export declare function readJarEntryAsBuffer(jarPath: string, entryPath: string): Promise<Buffer>;
42
+ export interface CappedJarEntry {
43
+ /** At most the requested byte budget of the entry (empty when maxBytes<=0). */
44
+ buffer: Buffer;
45
+ /** Full uncompressed size from the zip metadata, independent of the cap. */
46
+ entrySize: number;
47
+ }
48
+ /**
49
+ * Reads at most maxBytes of one entry, reporting the full uncompressed size
50
+ * from the zip metadata. Unlike readJarEntryAsBuffer, an oversized entry is
51
+ * never fully materialized: the stream is destroyed once the budget is
52
+ * exceeded and the collected prefix is returned. maxBytes<=0 skips the read
53
+ * entirely (metadata-only probe).
54
+ */
55
+ export declare function readJarEntryCapped(jarPath: string, entryPath: string, maxBytes: number): Promise<CappedJarEntry>;
56
+ export declare function decodeJarEntryUtf8OrThrow(contentBuffer: Buffer, jarPath: string, entryPath: string): string;
41
57
  export interface JarEntryReader {
42
58
  getEntryBuffer(entryPath: string): Promise<Buffer>;
43
59
  close(): void;
@@ -203,6 +203,88 @@ export async function readJarEntryAsBuffer(jarPath, entryPath) {
203
203
  }
204
204
  });
205
205
  }
206
+ /**
207
+ * Reads at most maxBytes of one entry, reporting the full uncompressed size
208
+ * from the zip metadata. Unlike readJarEntryAsBuffer, an oversized entry is
209
+ * never fully materialized: the stream is destroyed once the budget is
210
+ * exceeded and the collected prefix is returned. maxBytes<=0 skips the read
211
+ * entirely (metadata-only probe).
212
+ */
213
+ export async function readJarEntryCapped(jarPath, entryPath, maxBytes) {
214
+ const normalizedTargetPath = entryPath.replaceAll("\\", "/");
215
+ if (!isSecureJarEntryPath(normalizedTargetPath)) {
216
+ throw createError({
217
+ code: ERROR_CODES.INVALID_INPUT,
218
+ message: `Entry path "${normalizedTargetPath}" is not allowed.`,
219
+ details: { jarPath, entryPath: normalizedTargetPath }
220
+ });
221
+ }
222
+ return withZipFile(jarPath, async (zipFile) => {
223
+ while (true) {
224
+ const entry = await readNextEntry(zipFile);
225
+ if (!entry) {
226
+ throw createError({
227
+ code: ERROR_CODES.SOURCE_NOT_FOUND,
228
+ message: `Entry "${normalizedTargetPath}" was not found in "${jarPath}".`,
229
+ details: { jarPath, entryPath: normalizedTargetPath }
230
+ });
231
+ }
232
+ if (!isSecureJarEntryPath(entry.fileName)) {
233
+ continue;
234
+ }
235
+ if (entry.fileName !== normalizedTargetPath) {
236
+ continue;
237
+ }
238
+ const entrySize = entry.uncompressedSize;
239
+ if (maxBytes <= 0) {
240
+ return { buffer: Buffer.alloc(0), entrySize };
241
+ }
242
+ const buffer = await readEntryStreamPrefix(zipFile, entry, jarPath, maxBytes);
243
+ return { buffer: buffer.length > maxBytes ? buffer.slice(0, maxBytes) : buffer, entrySize };
244
+ }
245
+ });
246
+ }
247
+ /** Like readEntryStream, but resolves with the collected prefix instead of rejecting when the budget is exceeded. */
248
+ function readEntryStreamPrefix(zipFile, entry, jarPath, maxBytes) {
249
+ return new Promise((resolve, reject) => {
250
+ zipFile.openReadStream(entry, (error, stream) => {
251
+ if (error || !stream) {
252
+ reject(new Error(`Failed to read entry "${entry.fileName}" from "${jarPath}": ${toErrorMessage(error)}`));
253
+ return;
254
+ }
255
+ let settled = false;
256
+ let totalBytes = 0;
257
+ const chunks = [];
258
+ stream.on("data", (chunk) => {
259
+ if (settled)
260
+ return;
261
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
262
+ totalBytes += buf.length;
263
+ chunks.push(buf);
264
+ if (totalBytes >= maxBytes) {
265
+ settled = true;
266
+ stream.destroy();
267
+ resolve(Buffer.concat(chunks));
268
+ }
269
+ });
270
+ stream.once("error", (streamError) => {
271
+ if (settled)
272
+ return;
273
+ settled = true;
274
+ reject(new Error(`Failed to read entry "${entry.fileName}" from "${jarPath}": ${toErrorMessage(streamError)}`));
275
+ });
276
+ stream.once("end", () => {
277
+ if (settled)
278
+ return;
279
+ settled = true;
280
+ resolve(Buffer.concat(chunks));
281
+ });
282
+ });
283
+ });
284
+ }
285
+ export function decodeJarEntryUtf8OrThrow(contentBuffer, jarPath, entryPath) {
286
+ return decodeUtf8OrThrow(contentBuffer, jarPath, entryPath);
287
+ }
206
288
  /**
207
289
  * Opens a jar ONCE, drains its central directory into a name->entry index, and
208
290
  * serves repeated entry reads via O(1) lookup + openReadStream. Use this when a
@@ -140,6 +140,35 @@ export type GetArtifactFileOutput = {
140
140
  mappingApplied: SourceMapping;
141
141
  returnedNamespace: SourceMapping;
142
142
  artifactContents: ArtifactContentsSummary;
143
+ /**
144
+ * Present when the file was served directly from the backing jar instead of
145
+ * the source index (non-indexed assets/ and data/ text files).
146
+ */
147
+ deliveryMode?: "jar-read-through";
148
+ /** Set when content was withheld (e.g. a binary entry); explains why. */
149
+ contentOmittedReason?: string;
150
+ };
151
+ export type GetModClassMembersInput = {
152
+ jarPath: string;
153
+ className: string;
154
+ };
155
+ export type GetModClassMembersOutput = {
156
+ className: string;
157
+ jarPath: string;
158
+ members: {
159
+ constructors: SignatureMember[];
160
+ fields: SignatureMember[];
161
+ methods: SignatureMember[];
162
+ };
163
+ counts: {
164
+ constructors: number;
165
+ fields: number;
166
+ methods: number;
167
+ total: number;
168
+ };
169
+ /** Members are read from bytecode; no decompiler runs on this path. */
170
+ extractionMethod: "bytecode-only";
171
+ warnings: string[];
143
172
  };
144
173
  export type ListArtifactFilesInput = {
145
174
  artifactId: string;
@@ -256,6 +285,7 @@ export type GetClassMembersInput = {
256
285
  access?: MemberAccess;
257
286
  includeSynthetic?: boolean;
258
287
  includeInherited?: boolean;
288
+ includeAnnotations?: boolean;
259
289
  memberPattern?: string;
260
290
  maxMembers?: number;
261
291
  cursor?: string;
@@ -623,6 +653,12 @@ export declare class SourceService {
623
653
  getRegistryData(input: GetRegistryDataInput): Promise<GetRegistryDataOutput>;
624
654
  compareVersions(input: CompareVersionsInput): Promise<CompareVersionsOutput>;
625
655
  decompileModJar(input: DecompileModJarInput): Promise<DecompileModJarOutput>;
656
+ /**
657
+ * Member-level view of a third-party mod jar class, read from bytecode
658
+ * only — no decompiler is involved on this path, so it answers in
659
+ * milliseconds where a decompile-backed lookup costs a full Vineflower run.
660
+ */
661
+ getModClassMembers(input: GetModClassMembersInput): Promise<GetModClassMembersOutput>;
626
662
  getModClassSource(input: GetModClassSourceInput): Promise<GetModClassSourceOutput>;
627
663
  searchModSource(input: SearchModSourceInput): Promise<SearchModSourceOutput>;
628
664
  findMapping(input: FindMappingInput): Promise<FindMappingOutput>;
@@ -1,4 +1,6 @@
1
1
  import { loadConfig } from "./config.js";
2
+ import { ERROR_CODES, createError } from "./errors.js";
3
+ import { validateAndNormalizeJarPath } from "./path-resolver.js";
2
4
  import { MinecraftExplorerService } from "./minecraft-explorer-service.js";
3
5
  import { MappingService } from "./mapping-service.js";
4
6
  import { openDatabase } from "./storage/db.js";
@@ -124,6 +126,47 @@ export class SourceService {
124
126
  async decompileModJar(input) {
125
127
  return this.modDecompileService.decompileModJar(input);
126
128
  }
129
+ /**
130
+ * Member-level view of a third-party mod jar class, read from bytecode
131
+ * only — no decompiler is involved on this path, so it answers in
132
+ * milliseconds where a decompile-backed lookup costs a full Vineflower run.
133
+ */
134
+ async getModClassMembers(input) {
135
+ const jarPath = validateAndNormalizeJarPath(input.jarPath);
136
+ const className = input.className.trim();
137
+ if (!className) {
138
+ throw createError({
139
+ code: ERROR_CODES.INVALID_INPUT,
140
+ message: "className must be non-empty."
141
+ });
142
+ }
143
+ const signature = await this.explorerService.getSignature({
144
+ fqn: className,
145
+ jarPath,
146
+ access: "all"
147
+ });
148
+ const counts = {
149
+ constructors: signature.constructors.length,
150
+ fields: signature.fields.length,
151
+ methods: signature.methods.length,
152
+ total: signature.constructors.length + signature.fields.length + signature.methods.length
153
+ };
154
+ // No context block: contextForJar derives minecraftVersion/namespace from
155
+ // the jar path, which for an arbitrary mod jar greps the MOD's own
156
+ // version (e.g. geckolib-fabric-26.2.jar -> "26.2") — misleading here.
157
+ return {
158
+ className,
159
+ jarPath,
160
+ members: {
161
+ constructors: signature.constructors,
162
+ fields: signature.fields,
163
+ methods: signature.methods
164
+ },
165
+ counts,
166
+ extractionMethod: "bytecode-only",
167
+ warnings: signature.warnings
168
+ };
169
+ }
127
170
  async getModClassSource(input) {
128
171
  return this.modDecompileService.getModClassSource(input);
129
172
  }
@@ -143,12 +186,12 @@ export class SourceService {
143
186
  const result = await this.mappingService.checkSymbolExists(input);
144
187
  // On unobfuscated versions the mapping graph is empty, so the mapping service can
145
188
  // only ever report symbols as missing. We then validate against runtime bytecode.
146
- // Classes/fields surface as `mapping_unavailable` when the graph yields nothing, but
147
- // a method query with a non-empty graph returns `not_found` (empty member lookup)
148
- // which previously skipped the runtime fallback and produced false negatives for
149
- // methods that genuinely exist. Treat `not_found` methods as fallback-eligible.
150
- const fallbackEligible = result.status === "mapping_unavailable" ||
151
- (result.status === "not_found" && input.kind === "method");
189
+ // A non-empty graph that simply lacks the queried record returns `not_found` for
190
+ // ANY kind methods, fields, and classes alike and skipping the runtime check
191
+ // produced false negatives (e.g. a real EntityType.ITEM field reported missing).
192
+ // Every `not_found` is therefore fallback-eligible alongside `mapping_unavailable`;
193
+ // the runtime check itself keeps genuinely-missing symbols `not_found`.
194
+ const fallbackEligible = result.status === "mapping_unavailable" || result.status === "not_found";
152
195
  if (!fallbackEligible ||
153
196
  !isUnobfuscatedVersion(input.version) ||
154
197
  (input.sourceMapping !== "mojang" && input.sourceMapping !== "obfuscated")) {