@adhisang/minecraft-modding-mcp 6.1.1 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +12 -4
  3. package/dist/cache-registry.d.ts +1 -1
  4. package/dist/cache-registry.js +3 -0
  5. package/dist/entry-tools/analyze-mod-service.d.ts +12 -6
  6. package/dist/entry-tools/analyze-mod-service.js +37 -3
  7. package/dist/entry-tools/analyze-symbol-service.d.ts +6 -4
  8. package/dist/entry-tools/analyze-symbol-service.js +37 -2
  9. package/dist/entry-tools/inspect-minecraft/internal.d.ts +7 -3
  10. package/dist/entry-tools/inspect-minecraft/internal.js +43 -15
  11. package/dist/entry-tools/inspect-minecraft-service.d.ts +12 -12
  12. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  13. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  14. package/dist/error-mapping.d.ts +13 -0
  15. package/dist/error-mapping.js +35 -2
  16. package/dist/errors.d.ts +2 -0
  17. package/dist/errors.js +2 -0
  18. package/dist/index.js +39 -20
  19. package/dist/mapping/internal-types.d.ts +7 -0
  20. package/dist/mapping/types.d.ts +18 -0
  21. package/dist/mapping-service.js +16 -3
  22. package/dist/minecraft-explorer-service.d.ts +4 -0
  23. package/dist/minecraft-explorer-service.js +156 -17
  24. package/dist/mod-analyzer.d.ts +7 -0
  25. package/dist/mod-analyzer.js +28 -7
  26. package/dist/source/artifact-resolver.d.ts +2 -0
  27. package/dist/source/artifact-resolver.js +24 -1
  28. package/dist/source/class-source/members-builder.d.ts +4 -0
  29. package/dist/source/class-source/members-builder.js +3 -1
  30. package/dist/source/class-source.d.ts +3 -1
  31. package/dist/source/class-source.js +192 -19
  32. package/dist/source/did-you-mean.d.ts +14 -0
  33. package/dist/source/did-you-mean.js +79 -0
  34. package/dist/source/file-access.js +159 -3
  35. package/dist/source/indexer.js +72 -2
  36. package/dist/source/lifecycle/runtime-check.js +9 -5
  37. package/dist/source/nested-jars.d.ts +78 -0
  38. package/dist/source/nested-jars.js +267 -0
  39. package/dist/source/workspace-target.js +5 -2
  40. package/dist/source-jar-reader.d.ts +16 -0
  41. package/dist/source-jar-reader.js +82 -0
  42. package/dist/source-service.d.ts +37 -0
  43. package/dist/source-service.js +52 -6
  44. package/dist/stage-emitter.js +24 -8
  45. package/dist/stdio-supervisor.d.ts +92 -9
  46. package/dist/stdio-supervisor.js +915 -103
  47. package/dist/tool-contract-manifest.js +2 -2
  48. package/dist/tool-guidance.js +115 -7
  49. package/dist/tool-schemas.d.ts +1343 -149
  50. package/dist/tool-schemas.js +39 -7
  51. package/dist/types.d.ts +23 -0
  52. package/dist/workspace-mapping-service.d.ts +1 -0
  53. package/dist/workspace-mapping-service.js +120 -8
  54. package/docs/README-ja.md +4 -0
  55. package/docs/tool-reference.md +92 -6
  56. package/package.json +5 -5
@@ -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,78 @@
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
+ 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[]>;
31
+ /**
32
+ * Shell detection with both required signals: near-zero own classes AND
33
+ * bundled nested jars (META-INF/jars scan or fabric.mod.json "jars"
34
+ * declarations that really exist in the archive). Returns the inventory for
35
+ * shells and undefined for every regular jar.
36
+ */
37
+ export declare function detectShellJarInventory(jarPath: string): Promise<string[] | undefined>;
38
+ /**
39
+ * Content-addressed on-disk location of an extracted nested jar. The digest
40
+ * covers the outer jar path, its signature, and the entry name, so the same
41
+ * shell always maps to the same extracted file and re-resolution reuses it.
42
+ */
43
+ export declare function nestedJarCachePath(cacheDir: string, outerJarPath: string, outerSignature: string, entryName: string): string;
44
+ /**
45
+ * Extracts one nested jar to the content-addressed cache (no-op when already
46
+ * present). Entry-name safety is enforced by readJarEntryAsBuffer, and the
47
+ * on-disk name is the digest — never derived from the entry name — so a
48
+ * hostile entry name cannot escape the cache directory.
49
+ */
50
+ export declare function extractNestedJar(cacheDir: string, outerJarPath: string, outerSignature: string, entryName: string): Promise<string>;
51
+ /**
52
+ * Finds every nested jar of a shell that contains the class. Zero matches
53
+ * means the class genuinely is not bundled; more than one means the caller
54
+ * must return candidates instead of picking silently.
55
+ *
56
+ * The lookup is deliberately single-level: it inspects each nested jar's own
57
+ * class list only. A nested jar that is itself a shell surfaces its content
58
+ * when resolved directly as its own artifact.
59
+ */
60
+ export declare function findNestedJarsContainingClass(args: {
61
+ cacheDir: string;
62
+ outerJarPath: string;
63
+ outerSignature: string;
64
+ inventory: string[];
65
+ internalName: string;
66
+ }): Promise<NestedJarMatch[]>;
67
+ /**
68
+ * Resolves the single nested jar containing a class. Zero matches returns
69
+ * undefined (the caller keeps its not-found contract); several matches throw
70
+ * candidates instead of picking one silently.
71
+ */
72
+ export declare function resolveUniqueNestedJarForClass(args: {
73
+ cacheDir: string;
74
+ outerJarPath: string;
75
+ outerArtifactId: string;
76
+ inventory: string[];
77
+ className: string;
78
+ }): Promise<NestedJarMatch | undefined>;
@@ -0,0 +1,267 @@
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 { isSecureJarEntryPath } from "../path-resolver.js";
8
+ import { listJarEntries, readJarEntryAsBuffer, readJarEntryAsUtf8 } from "../source-jar-reader.js";
9
+ /**
10
+ * A jar with at most this many own `.class` entries can qualify as a
11
+ * Jar-in-Jar shell. Real Fabric API umbrella jars carry zero to a handful of
12
+ * marker classes while every API class lives in META-INF/jars.
13
+ */
14
+ export const SHELL_JAR_MAX_OUTER_CLASSES = 8;
15
+ export const NESTED_JAR_CACHE_DIRNAME = "nested-jars";
16
+ // In-process cache of class listings per extracted nested jar, so repeated
17
+ // class lookups against the same shell never re-open its nested jars. The
18
+ // shell's own inventory is persisted with the artifact record; this cache is
19
+ // only the per-nested-jar class membership.
20
+ const classSetCache = new Map();
21
+ const CLASS_SET_CACHE_MAX = 32;
22
+ function rememberClassSet(key, value) {
23
+ classSetCache.delete(key);
24
+ classSetCache.set(key, value);
25
+ while (classSetCache.size > CLASS_SET_CACHE_MAX) {
26
+ const oldest = classSetCache.keys().next().value;
27
+ if (oldest === undefined) {
28
+ break;
29
+ }
30
+ classSetCache.delete(oldest);
31
+ }
32
+ }
33
+ async function loadNestedJarClassSet(args) {
34
+ let extractedPath;
35
+ try {
36
+ extractedPath = await extractNestedJar(args.cacheDir, args.outerJarPath, args.outerSignature, args.entryName);
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ let classSet = classSetCache.get(extractedPath);
42
+ if (!classSet) {
43
+ try {
44
+ const entries = await listJarEntries(extractedPath);
45
+ classSet = new Set(entries.filter((entry) => entry.endsWith(".class") && isSecureJarEntryPath(entry)));
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ rememberClassSet(extractedPath, classSet);
51
+ }
52
+ return { extractedPath, classSet };
53
+ }
54
+ function nestedClassMatch(entry) {
55
+ const internalName = entry.slice(0, -".class".length);
56
+ if (internalName.startsWith("META-INF/versions/")) {
57
+ return undefined;
58
+ }
59
+ const binarySimpleName = internalName.split("/").at(-1) ?? internalName;
60
+ if (binarySimpleName === "module-info" || binarySimpleName === "package-info") {
61
+ return undefined;
62
+ }
63
+ const innerSegments = binarySimpleName.split("$").slice(1);
64
+ if (innerSegments.some((segment) => segment.length === 0 || /^\d/.test(segment))) {
65
+ return undefined;
66
+ }
67
+ return {
68
+ qualifiedName: internalName.replaceAll("/", ".").replaceAll("$", "."),
69
+ filePath: `${internalName.split("$")[0]}.java`,
70
+ line: 1,
71
+ symbolKind: "class"
72
+ };
73
+ }
74
+ /**
75
+ * Finds exact class-name matches across a shell's nested bytecode inventory.
76
+ * Results use the same binary-backed class representation as entry-tool search:
77
+ * an inferred outer Java path, line 1, and the broad class symbol kind.
78
+ */
79
+ export async function findNestedJarClasses(args) {
80
+ const normalizedQuery = args.className.trim().replaceAll("/", ".").replaceAll("$", ".");
81
+ const isQualified = normalizedQuery.includes(".");
82
+ const matches = new Map();
83
+ inventory: for (const entryName of args.inventory) {
84
+ const loaded = await loadNestedJarClassSet({
85
+ cacheDir: args.cacheDir,
86
+ outerJarPath: args.outerJarPath,
87
+ outerSignature: args.outerSignature,
88
+ entryName
89
+ });
90
+ if (!loaded) {
91
+ continue;
92
+ }
93
+ for (const classEntry of [...loaded.classSet].sort((left, right) => left.localeCompare(right))) {
94
+ const match = nestedClassMatch(classEntry);
95
+ if (!match) {
96
+ continue;
97
+ }
98
+ const simpleName = match.qualifiedName.split(".").at(-1) ?? match.qualifiedName;
99
+ if ((isQualified && match.qualifiedName !== normalizedQuery) ||
100
+ (!isQualified && simpleName !== normalizedQuery)) {
101
+ continue;
102
+ }
103
+ matches.set(match.qualifiedName, match);
104
+ if (isQualified || matches.size >= args.limit) {
105
+ break inventory;
106
+ }
107
+ }
108
+ }
109
+ return [...matches.values()];
110
+ }
111
+ /**
112
+ * Shell detection with both required signals: near-zero own classes AND
113
+ * bundled nested jars (META-INF/jars scan or fabric.mod.json "jars"
114
+ * declarations that really exist in the archive). Returns the inventory for
115
+ * shells and undefined for every regular jar.
116
+ */
117
+ export async function detectShellJarInventory(jarPath) {
118
+ let entries;
119
+ try {
120
+ entries = await listJarEntries(jarPath);
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ let ownClassCount = 0;
126
+ for (const entry of entries) {
127
+ if (entry.endsWith(".class")) {
128
+ ownClassCount += 1;
129
+ if (ownClassCount > SHELL_JAR_MAX_OUTER_CLASSES) {
130
+ return undefined;
131
+ }
132
+ }
133
+ }
134
+ let declared;
135
+ if (entries.includes("fabric.mod.json")) {
136
+ try {
137
+ const parsed = JSON.parse(await readJarEntryAsUtf8(jarPath, "fabric.mod.json"));
138
+ const jars = parsed?.jars;
139
+ if (Array.isArray(jars)) {
140
+ declared = jars
141
+ .map((entry) => entry?.file)
142
+ .filter((file) => typeof file === "string");
143
+ }
144
+ }
145
+ catch {
146
+ // Unreadable metadata leaves only the META-INF/jars scan signal.
147
+ }
148
+ }
149
+ const inventory = collectNestedJars(entries, declared);
150
+ return inventory.length > 0 ? inventory : undefined;
151
+ }
152
+ /**
153
+ * Content-addressed on-disk location of an extracted nested jar. The digest
154
+ * covers the outer jar path, its signature, and the entry name, so the same
155
+ * shell always maps to the same extracted file and re-resolution reuses it.
156
+ */
157
+ export function nestedJarCachePath(cacheDir, outerJarPath, outerSignature, entryName) {
158
+ const digest = createHash("sha256")
159
+ .update(`${outerJarPath}|${outerSignature}|${entryName}`)
160
+ .digest("hex");
161
+ return join(cacheDir, NESTED_JAR_CACHE_DIRNAME, `${digest}.jar`);
162
+ }
163
+ /**
164
+ * Extracts one nested jar to the content-addressed cache (no-op when already
165
+ * present). Entry-name safety is enforced by readJarEntryAsBuffer, and the
166
+ * on-disk name is the digest — never derived from the entry name — so a
167
+ * hostile entry name cannot escape the cache directory.
168
+ */
169
+ export async function extractNestedJar(cacheDir, outerJarPath, outerSignature, entryName) {
170
+ const finalPath = nestedJarCachePath(cacheDir, outerJarPath, outerSignature, entryName);
171
+ try {
172
+ await access(finalPath);
173
+ return finalPath;
174
+ }
175
+ catch {
176
+ // fall through to extraction
177
+ }
178
+ const bytes = await readJarEntryAsBuffer(outerJarPath, entryName);
179
+ await mkdir(dirname(finalPath), { recursive: true });
180
+ const tempPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}`;
181
+ await writeFile(tempPath, bytes);
182
+ try {
183
+ await rename(tempPath, finalPath);
184
+ }
185
+ catch (renameError) {
186
+ // A concurrent extraction of the same entry may have won the rename.
187
+ // The content-addressed final file being present makes this call a
188
+ // success; anything else is a real failure.
189
+ try {
190
+ await access(finalPath);
191
+ }
192
+ catch {
193
+ throw renameError;
194
+ }
195
+ await rm(tempPath, { force: true });
196
+ }
197
+ return finalPath;
198
+ }
199
+ /**
200
+ * Finds every nested jar of a shell that contains the class. Zero matches
201
+ * means the class genuinely is not bundled; more than one means the caller
202
+ * must return candidates instead of picking silently.
203
+ *
204
+ * The lookup is deliberately single-level: it inspects each nested jar's own
205
+ * class list only. A nested jar that is itself a shell surfaces its content
206
+ * when resolved directly as its own artifact.
207
+ */
208
+ export async function findNestedJarsContainingClass(args) {
209
+ const classEntry = `${args.internalName}.class`;
210
+ const qualifiedName = args.internalName.replaceAll("/", ".").replaceAll("$", ".");
211
+ const matches = [];
212
+ for (const entryName of args.inventory) {
213
+ const loaded = await loadNestedJarClassSet({
214
+ cacheDir: args.cacheDir,
215
+ outerJarPath: args.outerJarPath,
216
+ outerSignature: args.outerSignature,
217
+ entryName
218
+ });
219
+ const containsClass = loaded?.classSet.has(classEntry) || [...(loaded?.classSet ?? [])].some((entry) => entry.endsWith(".class") &&
220
+ entry.slice(0, -".class".length).replaceAll("/", ".").replaceAll("$", ".") === qualifiedName);
221
+ if (loaded && containsClass) {
222
+ matches.push({ entryName, extractedPath: loaded.extractedPath });
223
+ }
224
+ }
225
+ return matches;
226
+ }
227
+ /**
228
+ * Resolves the single nested jar containing a class. Zero matches returns
229
+ * undefined (the caller keeps its not-found contract); several matches throw
230
+ * candidates instead of picking one silently.
231
+ */
232
+ export async function resolveUniqueNestedJarForClass(args) {
233
+ const internalName = args.className.replace(/\./g, "/");
234
+ const matches = await findNestedJarsContainingClass({
235
+ cacheDir: args.cacheDir,
236
+ outerJarPath: args.outerJarPath,
237
+ outerSignature: args.outerArtifactId,
238
+ inventory: args.inventory,
239
+ internalName
240
+ });
241
+ if (matches.length === 0) {
242
+ return undefined;
243
+ }
244
+ const single = matches.length === 1 ? matches[0] : undefined;
245
+ if (single) {
246
+ return single;
247
+ }
248
+ throw createError({
249
+ code: ERROR_CODES.NESTED_JAR_AMBIGUOUS,
250
+ message: `Class "${args.className}" exists in ${matches.length} nested jars bundled by this shell jar; refusing to pick one automatically.`,
251
+ details: {
252
+ className: args.className,
253
+ shellArtifactId: args.outerArtifactId,
254
+ nestedJarCandidates: matches.map((match) => match.entryName),
255
+ nextAction: "Resolve the intended nested jar as its own artifact, then query the class against that artifactId.",
256
+ ...buildSuggestedCall({
257
+ tool: "resolve-artifact",
258
+ params: undefined,
259
+ examples: matches.map((match) => ({
260
+ params: { target: { kind: "jar", value: match.extractedPath } },
261
+ reason: `Query classes inside "${match.entryName}" directly.`
262
+ }))
263
+ })
264
+ }
265
+ });
266
+ }
267
+ //# 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