@adhisang/minecraft-modding-mcp 7.1.0 → 7.1.1

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 (58) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +1 -1
  3. package/dist/access-transformer-parser.d.ts +8 -0
  4. package/dist/access-transformer-parser.js +8 -1
  5. package/dist/access-widener-parser.d.ts +17 -0
  6. package/dist/access-widener-parser.js +12 -1
  7. package/dist/cache-registry.js +19 -6
  8. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +15 -9
  9. package/dist/entry-tools/manage-cache-service.js +10 -14
  10. package/dist/entry-tools/validate-project/cases/access-transformer.js +22 -3
  11. package/dist/entry-tools/validate-project/cases/access-widener.js +31 -4
  12. package/dist/entry-tools/validate-project/cases/mixin.js +11 -3
  13. package/dist/entry-tools/validate-project/cases/project-summary.js +24 -5
  14. package/dist/entry-tools/verify-mixin-target-service.js +19 -8
  15. package/dist/index.js +1 -0
  16. package/dist/java-process.d.ts +1 -0
  17. package/dist/java-process.js +14 -0
  18. package/dist/mapping/lookup.js +16 -1
  19. package/dist/mapping-service.d.ts +14 -0
  20. package/dist/mapping-service.js +35 -15
  21. package/dist/minecraft-explorer-service.js +70 -8
  22. package/dist/mixin/access-validators.js +38 -2
  23. package/dist/mixin/annotation-validators.js +137 -43
  24. package/dist/mixin/parsed-validator.js +21 -7
  25. package/dist/mixin-parser.d.ts +52 -0
  26. package/dist/mixin-parser.js +709 -130
  27. package/dist/mod-decompile-service.js +11 -1
  28. package/dist/mod-remap-service.js +6 -6
  29. package/dist/nbt/java-nbt-codec.js +7 -1
  30. package/dist/source/access-validate.js +10 -0
  31. package/dist/source/artifact-resolver.d.ts +13 -3
  32. package/dist/source/artifact-resolver.js +129 -18
  33. package/dist/source/class-source.js +13 -2
  34. package/dist/source/nested-jars.d.ts +15 -1
  35. package/dist/source/nested-jars.js +14 -5
  36. package/dist/source/search.d.ts +10 -2
  37. package/dist/source/search.js +60 -13
  38. package/dist/source/validate-mixin/pipeline/mapping-health.js +20 -1
  39. package/dist/source/validate-mixin/pipeline/target-lookup.js +16 -7
  40. package/dist/source/validate-mixin.d.ts +5 -0
  41. package/dist/source/validate-mixin.js +136 -21
  42. package/dist/source/workspace-target.js +75 -7
  43. package/dist/source-jar-reader.d.ts +15 -1
  44. package/dist/source-jar-reader.js +35 -3
  45. package/dist/source-resolver.js +2 -9
  46. package/dist/stdio-supervisor.d.ts +35 -1
  47. package/dist/stdio-supervisor.js +77 -2
  48. package/dist/storage/files-repo.d.ts +7 -0
  49. package/dist/storage/files-repo.js +17 -4
  50. package/dist/tool-contract-manifest.js +2 -2
  51. package/dist/tool-execution-gate.js +2 -1
  52. package/dist/version-service.js +7 -0
  53. package/dist/workspace-context-cache.d.ts +25 -0
  54. package/dist/workspace-context-cache.js +52 -2
  55. package/dist/workspace-mapping-service.js +116 -14
  56. package/docs/README-ja.md +1 -1
  57. package/docs/tool-reference.md +14 -11
  58. package/package.json +1 -1
@@ -135,7 +135,17 @@ export class ModDecompileService {
135
135
  const outPath = isAbsolute(input.outputFile)
136
136
  ? input.outputFile
137
137
  : resolvePath(input.outputFile);
138
- await writeFile(outPath, content, "utf8");
138
+ try {
139
+ await writeFile(outPath, content, "utf8");
140
+ }
141
+ catch (writeError) {
142
+ const errno = writeError?.code;
143
+ throw createError({
144
+ code: ERROR_CODES.INVALID_INPUT,
145
+ message: `outputFile "${outPath}" could not be written (${errno ?? "unknown error"}).`,
146
+ details: { field: "outputFile", value: outPath, osErrorCode: errno }
147
+ });
148
+ }
139
149
  outputFilePath = outPath;
140
150
  content = `[Written to ${outPath}]`;
141
151
  }
@@ -149,13 +149,13 @@ export async function remapModJar(input, config, deps = {}) {
149
149
  mkdirSync(cacheDir, { recursive: true });
150
150
  const cachedOutput = join(cacheDir, `${cacheKey}.jar`);
151
151
  if (!input.forceRemap && existsSync(cachedOutput)) {
152
- const cacheHitOutputJar = input.outputJar
153
- ? outputJar
154
- : cachedOutput;
155
- copyJarToDestination(cachedOutput, cacheHitOutputJar);
156
- log("info", "remap.cache-hit", { inputJar: normalizedInput, outputJar: cacheHitOutputJar });
152
+ // Always copy to the same `outputJar` location a cold run would use (the
153
+ // caller-supplied path, or resolveOutputJarPath's "next to input" default),
154
+ // so a cache hit and a cold run return the same path convention.
155
+ copyJarToDestination(cachedOutput, outputJar);
156
+ log("info", "remap.cache-hit", { inputJar: normalizedInput, outputJar });
157
157
  return {
158
- outputJar: cacheHitOutputJar,
158
+ outputJar,
159
159
  mcVersion,
160
160
  fromMapping: fromNamespace,
161
161
  targetMapping: input.targetMapping,
@@ -480,7 +480,13 @@ export function decodeJavaNbt(buffer) {
480
480
  throw error;
481
481
  }
482
482
  }
483
- throw parseError("Failed to decode Java NBT payload.");
483
+ // Unlike every other parseError() call site in this module, nothing here
484
+ // has an offset to report - do not inherit PARSE_NEXT_ACTION's "at the
485
+ // reported offset" promise.
486
+ throw parseError("Failed to decode Java NBT payload.", {
487
+ nextAction: "The payload is not a well-formed Java NBT stream. Check that nbtBase64 holds the whole " +
488
+ 'file and that compression matches how it was written (use compression "auto" to detect gzip).'
489
+ });
484
490
  }
485
491
  }
486
492
  export function encodeJavaNbt(document) {
@@ -80,6 +80,16 @@ export async function validateAccessWidener(svc, input) {
80
80
  if (!content.trim()) {
81
81
  throw createError({ code: ERROR_CODES.INVALID_INPUT, message: "content must be non-empty." });
82
82
  }
83
+ // Fabric Loader also reads the classTweaker format; it is out of scope here rather than an
84
+ // accessWidener file with a bad header, so it gets no verdict.
85
+ const header = content.split(/\r?\n/).map((line) => line.trim()).find((line) => line && !line.startsWith("#"));
86
+ if (header && /^classTweaker(\s|$)/.test(header)) {
87
+ throw createError({
88
+ code: ERROR_CODES.INVALID_INPUT,
89
+ message: `The classTweaker format is not supported (header "${header}"); only accessWidener v1/v2 files can be validated.`,
90
+ details: { unsupportedFormat: "classTweaker", header }
91
+ });
92
+ }
83
93
  const warnings = [];
84
94
  const parsed = parseAccessWidener(content);
85
95
  const headerNamespaceRaw = normalizeOptionalString(parsed.namespace);
@@ -140,8 +140,16 @@ export declare function hasLoaderRuntimeVersionToken(path: string, mcVersion: st
140
140
  /**
141
141
  * Minecraft version a runtime jar path carries, or undefined when the path
142
142
  * names none. Loom lays its cache out as
143
- * `<gradle>/caches/fabric-loom/<mcVersion>/...`, so the first `1.x[.y]` token
144
- * of the path is the version the jar was built for.
143
+ * `<gradle>/caches/fabric-loom/<mcVersion>/...`, so the first token of the
144
+ * tool-written part of the path ({@link runtimeJarLayoutTail}; the whole path
145
+ * when it enters no layout root) that names a Minecraft version is the version the jar was built for — in
146
+ * either id family, and a pre-release names itself and not the release it
147
+ * precedes. Reading only `1.x[.y]` left every 26.x jar versionless, so no
148
+ * approximation was ever reported for one.
149
+ *
150
+ * A legacy `1.x` token is self-identifying; a 26.1+ token shares its shape with
151
+ * every loader's own version number, so it additionally has to sit where
152
+ * {@link tokenPositionNamesMinecraft} says Minecraft's version sits.
145
153
  */
146
154
  export declare function inferRuntimeJarMinecraftVersion(path: string): string | undefined;
147
155
  /**
@@ -177,7 +185,9 @@ export declare function inferRuntimeJarLoader(path: string): RuntimeLoader;
177
185
  * `version` becomes the version the SERVED jar carries; the caller's original
178
186
  * request is preserved under `requestedVersion` and flagged. A served loader
179
187
  * that contradicts a KNOWN expected loader is flagged too — never silently
180
- * dropped.
188
+ * dropped. When the jar's path names no version at all, the request stands as
189
+ * the answer and nothing is flagged: there is no served version to contradict
190
+ * it with.
181
191
  */
182
192
  export declare function describeServedRuntimeJar(input: {
183
193
  jarPath: string;
@@ -170,16 +170,30 @@ export function looksLikeMinecraftArtifactPath(path) {
170
170
  function looksLikeMinecraftSourceArtifact(path, hasMinecraftNamespace) {
171
171
  return hasMinecraftNamespace || looksLikeMinecraftArtifactPath(path);
172
172
  }
173
+ /**
174
+ * What a Minecraft version id may be FOLLOWED by and still be the same id:
175
+ * nothing. A pre-release suffix extends the id rather than decorating it —
176
+ * `26.1-pre-1` is its own version and not `26.1` — so the hyphen forms Mojang
177
+ * publishes (`-pre1`, `-rc1`, `-pre-6`, `-rc-2`, `-snapshot-6`) have to close
178
+ * the token as firmly as a trailing digit does. Any OTHER hyphen is ordinary
179
+ * path decoration (`minecraft-1.21.1-merged.jar`) and must keep matching.
180
+ *
181
+ * The suffix grammar is `isUnobfuscatedVersion`'s in version-service.ts; the
182
+ * two are read together whenever either changes.
183
+ */
184
+ const VERSION_ID_SUFFIX_LOOKAHEAD = "-(?:pre|rc)[0-9]|-(?:pre|rc|snapshot)-[0-9]";
173
185
  export function hasExactVersionToken(path, version) {
174
186
  const normalizedPath = normalizePathStyle(path).toLowerCase();
175
187
  const normalizedVersion = version.trim().toLowerCase();
176
188
  if (!normalizedVersion) {
177
189
  return false;
178
190
  }
179
- // Avoid prefix false-positives like "1.21.1" matching "1.21.10".
180
- const cached = VERSION_TOKEN_REGEX_CACHE.get(normalizedVersion);
191
+ // Avoid prefix false-positives like "1.21.1" matching "1.21.10", and
192
+ // pre-release false-positives like "26.1" matching "26.1-pre-1".
193
+ const cacheKey = normalizedVersion;
194
+ const cached = VERSION_TOKEN_REGEX_CACHE.get(cacheKey);
181
195
  const pattern = cached
182
- ?? rememberCachedRegex(VERSION_TOKEN_REGEX_CACHE, normalizedVersion, new RegExp(`(^|[^0-9a-z])${escapeRegexLiteral(normalizedVersion)}(?![0-9a-z]|\\.[0-9])`, "i"));
196
+ ?? rememberCachedRegex(VERSION_TOKEN_REGEX_CACHE, cacheKey, new RegExp(`(^|[^0-9a-z])${escapeRegexLiteral(normalizedVersion)}(?![0-9a-z]|\\.[0-9]|${VERSION_ID_SUFFIX_LOOKAHEAD})`, "i"));
183
197
  return pattern.test(normalizedPath);
184
198
  }
185
199
  /**
@@ -209,15 +223,85 @@ export function hasLoaderRuntimeVersionToken(path, mcVersion) {
209
223
  ?? rememberCachedRegex(LOADER_VERSION_TOKEN_REGEX_CACHE, cacheKey, new RegExp(`(^|[^0-9a-z.])${minor}\\.${patch}\\.\\d`, "i"));
210
224
  return pattern.test(normalizedPath);
211
225
  }
212
- const RUNTIME_JAR_VERSION_REGEX = /(?:^|[^0-9.])(1\.\d+(?:\.\d+)?)(?![0-9.])/;
226
+ /**
227
+ * Every version-SHAPED token a path carries, in both id families: `<a>.<b>[.<c>]`
228
+ * with an optional pre-release suffix, and the `YYwNNa` snapshot form. Shape
229
+ * only — `inferRuntimeJarMinecraftVersion` decides which of them is Minecraft's.
230
+ *
231
+ * The alternatives are greedy, so the LONGEST token at a position is the one
232
+ * offered, suffix included. What closes it is another version component — a
233
+ * digit, or a dot that starts one — and nothing else: a dot that starts a file
234
+ * extension ends the token instead. Refusing every following dot made
235
+ * `minecraft-merged-26.1.jar` name no version and made
236
+ * `minecraft-merged-26.2-pre-6.jar` backtrack off its own suffix down to
237
+ * "26.2", the release that jar is not.
238
+ */
239
+ const RUNTIME_JAR_VERSION_TOKEN_REGEX = /(?:^|[^0-9.])(\d+\.\d+(?:\.\d+)?(?:-(?:pre|rc)\d+|-(?:pre|rc|snapshot)-\d+)?|\d{2}w\d{2}[a-z])(?![0-9]|\.[0-9])/g;
240
+ /** The legacy obfuscated line, `1.21` / `1.21.10` / `1.14.4-pre7`. */
241
+ const LEGACY_MINECRAFT_VERSION_REGEX = /^1\.\d+(?:\.\d+)?(?:-(?:pre|rc)\d+|-(?:pre|rc|snapshot)-\d+)?$/;
242
+ /** Loom's cache root: the directory whose immediate children are Minecraft versions. */
243
+ const LOOM_CACHE_ROOT_SEGMENT_REGEX = /^(?:fabric-loom|loom-cache)$/;
244
+ /**
245
+ * Does the path POSITION a 26.1+ token sits at vouch for it being Minecraft's
246
+ * version?
247
+ *
248
+ * The new-format grammar is only a shape: `isUnobfuscatedVersion` accepts any
249
+ * id from year 26 up, and Forge's own versions have long since passed it
250
+ * (`forge-47.3.0-srg.jar`), so the token needs the same kind of corroboration
251
+ * the loader-version exclusions elsewhere in this file rely on. Three positions
252
+ * carry it, and they are the layouts the discovery helpers already walk: the
253
+ * artifact itself names Minecraft (`minecraft-merged-26.1.jar`), the entry
254
+ * directly inside the version directory does (neoformruntime's
255
+ * `<version>/minecraft-joined.jar`), or the version directory is a child of
256
+ * Loom's cache root (`<...>/fabric-loom/<mcVersion>/`).
257
+ *
258
+ * Only the token's own neighbourhood counts, never an ancestor — for the reason
259
+ * `runtimeJarLayoutRelativePath` exists: a checkout under `~/dev/minecraft/`
260
+ * names the user's directory, not the jar.
261
+ */
262
+ function tokenPositionNamesMinecraft(segments, index) {
263
+ return ((segments[index]?.includes("minecraft") ?? false) ||
264
+ (segments[index + 1]?.includes("minecraft") ?? false) ||
265
+ LOOM_CACHE_ROOT_SEGMENT_REGEX.test(segments[index - 1] ?? ""));
266
+ }
213
267
  /**
214
268
  * Minecraft version a runtime jar path carries, or undefined when the path
215
269
  * names none. Loom lays its cache out as
216
- * `<gradle>/caches/fabric-loom/<mcVersion>/...`, so the first `1.x[.y]` token
217
- * of the path is the version the jar was built for.
270
+ * `<gradle>/caches/fabric-loom/<mcVersion>/...`, so the first token of the
271
+ * tool-written part of the path ({@link runtimeJarLayoutTail}; the whole path
272
+ * when it enters no layout root) that names a Minecraft version is the version the jar was built for — in
273
+ * either id family, and a pre-release names itself and not the release it
274
+ * precedes. Reading only `1.x[.y]` left every 26.x jar versionless, so no
275
+ * approximation was ever reported for one.
276
+ *
277
+ * A legacy `1.x` token is self-identifying; a 26.1+ token shares its shape with
278
+ * every loader's own version number, so it additionally has to sit where
279
+ * {@link tokenPositionNamesMinecraft} says Minecraft's version sits.
218
280
  */
219
281
  export function inferRuntimeJarMinecraftVersion(path) {
220
- return RUNTIME_JAR_VERSION_REGEX.exec(normalizePathStyle(path))?.[1];
282
+ // A path that enters no layout root, such as a custom Gradle home's `loom-cache`, is read whole.
283
+ const normalizedPath = runtimeJarLayoutTail(path) ?? normalizePathStyle(path).toLowerCase();
284
+ const segments = normalizedPath.split("/");
285
+ for (const match of normalizedPath.matchAll(RUNTIME_JAR_VERSION_TOKEN_REGEX)) {
286
+ const token = match[1];
287
+ if (!token) {
288
+ continue;
289
+ }
290
+ if (LEGACY_MINECRAFT_VERSION_REGEX.test(token)) {
291
+ return token;
292
+ }
293
+ if (!isUnobfuscatedVersion(token)) {
294
+ continue;
295
+ }
296
+ // `match[0]` carries the leading boundary character, so the token starts at
297
+ // its end minus the token's own length.
298
+ const tokenStart = (match.index ?? 0) + match[0].length - token.length;
299
+ const segmentIndex = normalizedPath.slice(0, tokenStart).split("/").length - 1;
300
+ if (tokenPositionNamesMinecraft(segments, segmentIndex)) {
301
+ return token;
302
+ }
303
+ }
304
+ return undefined;
221
305
  }
222
306
  /**
223
307
  * The Minecraft version a `kind: "jar"` target PROVES it is the unobfuscated
@@ -271,6 +355,10 @@ const RUNTIME_JAR_LAYOUT_ROOT_RE = /^(?:\.[^/]*|caches|build)$/;
271
355
  * loader for an artifact that names none.
272
356
  */
273
357
  function runtimeJarLayoutRelativePath(path) {
358
+ return runtimeJarLayoutTail(path) ?? normalizePathStyle(path).toLowerCase().split("/").filter(Boolean).at(-1) ?? "";
359
+ }
360
+ /** Everything below the deepest layout root, or undefined when the path enters none. */
361
+ function runtimeJarLayoutTail(path) {
274
362
  const segments = normalizePathStyle(path)
275
363
  .toLowerCase()
276
364
  .split("/")
@@ -280,7 +368,7 @@ function runtimeJarLayoutRelativePath(path) {
280
368
  return segments.slice(index + 1).join("/");
281
369
  }
282
370
  }
283
- return segments[segments.length - 1] ?? "";
371
+ return undefined;
284
372
  }
285
373
  /**
286
374
  * Loader a runtime jar belongs to, read from the tool-written part of its path.
@@ -320,7 +408,9 @@ export function inferRuntimeJarLoader(path) {
320
408
  * `version` becomes the version the SERVED jar carries; the caller's original
321
409
  * request is preserved under `requestedVersion` and flagged. A served loader
322
410
  * that contradicts a KNOWN expected loader is flagged too — never silently
323
- * dropped.
411
+ * dropped. When the jar's path names no version at all, the request stands as
412
+ * the answer and nothing is flagged: there is no served version to contradict
413
+ * it with.
324
414
  */
325
415
  export function describeServedRuntimeJar(input) {
326
416
  const notes = [];
@@ -527,8 +617,9 @@ export async function probeMinecraftArtifact(svc, input) {
527
617
  let value = input.target.value.trim();
528
618
  const warnings = [];
529
619
  const requestedMapping = normalizeMapping(input.mapping);
530
- if (input.preferProjectVersion && input.projectPath) {
531
- const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(input.projectPath);
620
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
621
+ if (input.preferProjectVersion && normalizedProjectPath) {
622
+ const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
532
623
  if (detected && detected !== value) {
533
624
  warnings.push(`Overriding version "${value}" with project version "${detected}" from gradle.properties.`);
534
625
  }
@@ -580,7 +671,7 @@ export async function probeMinecraftArtifact(svc, input) {
580
671
  }
581
672
  const versionSourceDiscovery = await svc.discoverVersionSourceJar({
582
673
  version: resolvedVersion,
583
- projectPath: input.projectPath,
674
+ projectPath: normalizedProjectPath,
584
675
  gradleUserHome: input.gradleUserHome
585
676
  });
586
677
  if (!versionSourceDiscovery.selectedSourceJarPath) {
@@ -1128,10 +1219,11 @@ export async function resolveVersionContext(svc, input) {
1128
1219
  if (inferredVersion) {
1129
1220
  return inferredVersion;
1130
1221
  }
1131
- if (!input.preferProjectVersion || !input.projectPath) {
1222
+ const normalizedProjectPath = normalizeOptionalProjectPath(input.projectPath);
1223
+ if (!input.preferProjectVersion || !normalizedProjectPath) {
1132
1224
  return undefined;
1133
1225
  }
1134
- const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(input.projectPath);
1226
+ const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
1135
1227
  if (detected) {
1136
1228
  input.warnings.push(`Using project version "${detected}" from gradle.properties because the artifact metadata did not include a version.`);
1137
1229
  }
@@ -1268,7 +1360,7 @@ export async function buildMappingFallbackSuggestedCall(svc, args) {
1268
1360
  if (process.env.WORKSPACE_FALLBACK_LEGACY === "1") {
1269
1361
  return buildLegacyMappingFallback({ kind, value, scope, isVanillaMojang, projectPath: input.projectPath });
1270
1362
  }
1271
- const projectPath = input.projectPath?.trim();
1363
+ const projectPath = normalizeOptionalProjectPath(input.projectPath);
1272
1364
  if (!projectPath) {
1273
1365
  return buildLegacyMappingFallback({ kind, value, scope, isVanillaMojang, projectPath: undefined });
1274
1366
  }
@@ -1428,8 +1520,15 @@ export async function resolveArtifact(svc, input) {
1428
1520
  const mapping = normalizeMapping(input.mapping);
1429
1521
  const scope = input.scope;
1430
1522
  const warnings = [...synthesisWarnings];
1431
- if (input.preferProjectVersion && input.projectPath && kind === "version") {
1432
- const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(input.projectPath);
1523
+ // Normalized only for the readers that need it: every other use of
1524
+ // projectPath below reaches a helper that normalizes on its own, and doing it
1525
+ // here unconditionally would newly refuse a malformed path on calls that
1526
+ // never read one.
1527
+ const preferProjectVersionPath = input.preferProjectVersion
1528
+ ? normalizeOptionalProjectPath(input.projectPath)
1529
+ : undefined;
1530
+ if (preferProjectVersionPath && kind === "version") {
1531
+ const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(preferProjectVersionPath);
1433
1532
  if (detected && detected !== value) {
1434
1533
  warnings.push(`Overriding version "${value}" with project version "${detected}" from gradle.properties.`);
1435
1534
  }
@@ -1640,9 +1739,21 @@ export async function resolveArtifact(svc, input) {
1640
1739
  mapping: effectiveMapping,
1641
1740
  target: { kind, value },
1642
1741
  nextAction: "Use target: { kind: \"version\", value } or a versioned Maven coordinate so mapping artifacts can be resolved.",
1742
+ // This branch is reached precisely because no version is known, and
1743
+ // `value` is whatever the caller's own target carried - a jar path
1744
+ // for the kind that gets here. Replaying it as a version target
1745
+ // produced a `suggestedCall` that could never succeed, so the
1746
+ // version goes out as a placeholder the caller must fill in;
1747
+ // buildSuggestedCall routes such a template to exampleCalls.
1643
1748
  ...buildSuggestedCall({
1644
1749
  tool: "resolve-artifact",
1645
- params: buildResolveArtifactParams({ kind: "version", value }, { ...(scope ? { scope } : {}) })
1750
+ params: undefined,
1751
+ examples: [
1752
+ {
1753
+ params: buildResolveArtifactParams({ kind: "version", value: "<your-mc-version>" }, { ...(scope ? { scope } : {}) }),
1754
+ reason: `Replace <your-mc-version> with the Minecraft version this artifact belongs to; ${effectiveMapping} mappings are resolved per version.`
1755
+ }
1756
+ ]
1646
1757
  })
1647
1758
  }
1648
1759
  });
@@ -626,7 +626,8 @@ export function findClass(svc, input) {
626
626
  qualifiedName: isInnerMatch ? className : rowQualified,
627
627
  filePath: row.filePath,
628
628
  line: row.line,
629
- symbolKind: row.symbolKind
629
+ symbolKind: row.symbolKind,
630
+ ...(isInnerMatch ? { nested: true, enclosingClass: rowQualified } : {})
630
631
  };
631
632
  })
632
633
  .slice(0, limit);
@@ -1115,7 +1116,17 @@ export async function getClassSource(svc, input) {
1115
1116
  const outputPath = isAbsolute(outputFile)
1116
1117
  ? outputFile
1117
1118
  : resolvePath(outputFile);
1118
- await writeFile(outputPath, sourceText, "utf8");
1119
+ try {
1120
+ await writeFile(outputPath, sourceText, "utf8");
1121
+ }
1122
+ catch (writeError) {
1123
+ const errno = writeError?.code;
1124
+ throw createError({
1125
+ code: ERROR_CODES.INVALID_INPUT,
1126
+ message: `outputFile "${outputPath}" could not be written (${errno ?? "unknown error"}).`,
1127
+ details: { field: "outputFile", value: outputPath, osErrorCode: errno }
1128
+ });
1129
+ }
1119
1130
  resolvedOutputFile = outputPath;
1120
1131
  sourceText = `[Written to ${outputPath}]`;
1121
1132
  }
@@ -1,3 +1,4 @@
1
+ import { rename } from "node:fs/promises";
1
2
  /**
2
3
  * A jar with at most this many own `.class` entries can qualify as a
3
4
  * Jar-in-Jar shell. Real Fabric API umbrella jars carry zero to a handful of
@@ -23,6 +24,10 @@ export interface NestedClassMatch {
23
24
  filePath: string;
24
25
  line: number;
25
26
  symbolKind: "class";
27
+ /** Set when the match is a type declared INSIDE another type. */
28
+ nested?: boolean;
29
+ /** The enclosing top-level type, present only on a nested match. */
30
+ enclosingClass?: string;
26
31
  }
27
32
  /**
28
33
  * Finds exact class-name matches across a shell's nested bytecode inventory.
@@ -50,6 +55,15 @@ export declare function detectShellJarInventory(jarPath: string): Promise<string
50
55
  * shell always maps to the same extracted file and re-resolution reuses it.
51
56
  */
52
57
  export declare function nestedJarCachePath(cacheDir: string, outerJarPath: string, outerSignature: string, entryName: string): string;
58
+ /**
59
+ * Seam for injecting the temp->final rename primitive so tests can simulate a
60
+ * non-race rename failure (e.g. EBUSY/EPERM/ENOSPC), which cannot be provoked
61
+ * for real against a plain filesystem under the allowed test commands.
62
+ * Defaults to the module-level `rename` from node:fs/promises.
63
+ */
64
+ export interface ExtractNestedJarDeps {
65
+ rename?: typeof rename;
66
+ }
53
67
  /**
54
68
  * Extracts one nested jar to the content-addressed cache (no-op when already
55
69
  * present). Entry-name safety is enforced by readJarEntryAsBuffer, and the
@@ -62,7 +76,7 @@ export declare function nestedJarCachePath(cacheDir: string, outerJarPath: strin
62
76
  * one extraction with ERR_LIMIT_EXCEEDED — `loadNestedJarClassSet` swallows it
63
77
  * and keeps resolving the shell's other nested jars.
64
78
  */
65
- export declare function extractNestedJar(cacheDir: string, outerJarPath: string, outerSignature: string, entryName: string, maxEntryBytes?: number): Promise<string>;
79
+ export declare function extractNestedJar(cacheDir: string, outerJarPath: string, outerSignature: string, entryName: string, maxEntryBytes?: number, deps?: ExtractNestedJarDeps): Promise<string>;
66
80
  /**
67
81
  * Finds every nested jar of a shell that contains the class. Zero matches
68
82
  * means the class genuinely is not bundled; more than one means the caller
@@ -98,11 +98,17 @@ function nestedClassMatch(entry) {
98
98
  if (innerSegments.some((segment) => segment.length === 0 || /^\d/.test(segment))) {
99
99
  return undefined;
100
100
  }
101
+ // Mirror the indexed find-class branch's shape (class-source.ts): a `$`
102
+ // inner-class match reports `nested`/`enclosingClass` against its top-level
103
+ // enclosing type, exactly like an indexed lookup would.
104
+ const outerBinaryName = internalName.split("$")[0];
105
+ const isNested = innerSegments.length > 0;
101
106
  return {
102
107
  qualifiedName: internalName.replaceAll("/", ".").replaceAll("$", "."),
103
- filePath: `${internalName.split("$")[0]}.java`,
108
+ filePath: `${outerBinaryName}.java`,
104
109
  line: 1,
105
- symbolKind: "class"
110
+ symbolKind: "class",
111
+ ...(isNested ? { nested: true, enclosingClass: outerBinaryName.replaceAll("/", ".") } : {})
106
112
  };
107
113
  }
108
114
  /**
@@ -206,7 +212,8 @@ export function nestedJarCachePath(cacheDir, outerJarPath, outerSignature, entry
206
212
  * one extraction with ERR_LIMIT_EXCEEDED — `loadNestedJarClassSet` swallows it
207
213
  * and keeps resolving the shell's other nested jars.
208
214
  */
209
- export async function extractNestedJar(cacheDir, outerJarPath, outerSignature, entryName, maxEntryBytes) {
215
+ export async function extractNestedJar(cacheDir, outerJarPath, outerSignature, entryName, maxEntryBytes, deps = {}) {
216
+ const renameFn = deps.rename ?? rename;
210
217
  const finalPath = nestedJarCachePath(cacheDir, outerJarPath, outerSignature, entryName);
211
218
  try {
212
219
  await access(finalPath);
@@ -243,16 +250,18 @@ export async function extractNestedJar(cacheDir, outerJarPath, outerSignature, e
243
250
  const tempPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString("hex")}`;
244
251
  await writeFile(tempPath, bytes);
245
252
  try {
246
- await rename(tempPath, finalPath);
253
+ await renameFn(tempPath, finalPath);
247
254
  }
248
255
  catch (renameError) {
249
256
  // A concurrent extraction of the same entry may have won the rename.
250
257
  // The content-addressed final file being present makes this call a
251
- // success; anything else is a real failure.
258
+ // success; anything else is a real failure. Either way, best-effort
259
+ // remove the orphaned temp file before returning/rethrowing.
252
260
  try {
253
261
  await access(finalPath);
254
262
  }
255
263
  catch {
264
+ await rm(tempPath, { force: true }).catch(() => { });
256
265
  throw renameError;
257
266
  }
258
267
  await rm(tempPath, { force: true });
@@ -22,6 +22,14 @@ export declare function canUseIndexedSearchPath(indexedSearchEnabled: boolean, i
22
22
  export declare function buildGlobRegex(pattern: string): RegExp;
23
23
  export declare function globToSqlLike(pattern: string): string;
24
24
  export declare function checkPackagePrefix(filePath: string, packagePrefix?: string): boolean;
25
+ /**
26
+ * A literal file_path prefix every path accepted by the scope starts with, or
27
+ * undefined when the scope implies none. The indexed candidate query matches it
28
+ * with an ASCII case-insensitive LIKE, a superset of checkPackagePrefix and the
29
+ * glob regex (indexed paths are stored with `/` separators), so both exact
30
+ * checks still run on every candidate.
31
+ */
32
+ export declare function indexedScopePathPrefix(scope: SearchScope | undefined): string | undefined;
25
33
  export declare function buildSearchCursorContext(input: {
26
34
  artifactId: string;
27
35
  query: string;
@@ -42,8 +50,8 @@ export declare function scorePathMatch(match: SearchMatch, index: number): numbe
42
50
  export declare function matchRegexIndex(target: string, regex: RegExp): number;
43
51
  export declare function searchClassSource(svc: SourceService, input: SearchClassSourceInput): Promise<SearchClassSourceOutput>;
44
52
  export declare function searchSymbolIntent(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined, onHit: (hit: SearchSourceHit) => void): void;
45
- export declare function searchTextIntentIndexed(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, onHit: (hit: SearchSourceHit) => void): void;
46
- export declare function searchPathIntentIndexed(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, onHit: (hit: SearchSourceHit) => void): void;
53
+ export declare function searchTextIntentIndexed(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, onHit: (hit: SearchSourceHit) => void): boolean;
54
+ export declare function searchPathIntentIndexed(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, onHit: (hit: SearchSourceHit) => void): boolean;
47
55
  export declare function searchTextIntent(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined, onHit: (hit: SearchSourceHit) => void, onWarning?: (warning: string) => void): void;
48
56
  export declare function searchPathIntent(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined, onHit: (hit: SearchSourceHit) => void): void;
49
57
  export declare function findSymbolHits(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined): IndexedSymbolHit[];
@@ -49,7 +49,9 @@ export function canUseIndexedSearchPath(indexedSearchEnabled, intent, match, _sc
49
49
  if (match === "regex") {
50
50
  return false;
51
51
  }
52
- // packagePrefix and fileGlob are applied as post-filters on indexed candidates.
52
+ // packagePrefix and a fileGlob's literal directory prefix narrow the indexed
53
+ // candidate query (see indexedScopePathPrefix); both scope filters are also
54
+ // checked exactly on every candidate.
53
55
  return true;
54
56
  }
55
57
  export function buildGlobRegex(pattern) {
@@ -108,12 +110,36 @@ export function globToSqlLike(pattern) {
108
110
  }
109
111
  return result;
110
112
  }
113
+ function packagePrefixPath(packagePrefix) {
114
+ return `${packagePrefix.replace(/\.+/g, "/").replace(/\/+$/, "")}/`;
115
+ }
111
116
  export function checkPackagePrefix(filePath, packagePrefix) {
112
117
  if (!packagePrefix) {
113
118
  return true;
114
119
  }
115
- const normalizedPrefix = packagePrefix.replace(/\.+/g, "/").replace(/\/+$/, "");
116
- return normalizePathStyle(filePath).startsWith(`${normalizedPrefix}/`);
120
+ return normalizePathStyle(filePath).startsWith(packagePrefixPath(packagePrefix));
121
+ }
122
+ /**
123
+ * A literal file_path prefix every path accepted by the scope starts with, or
124
+ * undefined when the scope implies none. The indexed candidate query matches it
125
+ * with an ASCII case-insensitive LIKE, a superset of checkPackagePrefix and the
126
+ * glob regex (indexed paths are stored with `/` separators), so both exact
127
+ * checks still run on every candidate.
128
+ */
129
+ export function indexedScopePathPrefix(scope) {
130
+ const packagePath = scope?.packagePrefix ? packagePrefixPath(scope.packagePrefix) : undefined;
131
+ let globPath;
132
+ if (scope?.fileGlob) {
133
+ // buildGlobRegex treats everything before the first `*`/`?` as literal.
134
+ const glob = normalizePathStyle(scope.fileGlob);
135
+ const wildcardIndex = glob.search(/[*?]/);
136
+ globPath = (wildcardIndex < 0 ? glob : glob.slice(0, wildcardIndex)) || undefined;
137
+ }
138
+ if (packagePath && globPath) {
139
+ // Keep the longer when one extends the other; otherwise either is a superset.
140
+ return globPath.startsWith(packagePath) ? globPath : packagePath;
141
+ }
142
+ return packagePath ?? globPath;
117
143
  }
118
144
  export function buildSearchCursorContext(input) {
119
145
  return JSON.stringify({
@@ -363,6 +389,7 @@ export async function searchClassSource(svc, input) {
363
389
  searchWarnings.push(warning);
364
390
  };
365
391
  const tokenOnlyTextIntent = intent === "text" && queryMode === "token";
392
+ let indexedWindowFull = false;
366
393
  if (intent === "symbol") {
367
394
  searchSymbolIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
368
395
  }
@@ -384,12 +411,9 @@ export async function searchClassSource(svc, input) {
384
411
  }
385
412
  else if (canUseIndexedSearchPath(indexedSearchEnabled, intent, match, scope)) {
386
413
  try {
387
- if (intent === "path") {
388
- searchPathIntentIndexed(svc, artifact.artifactId, query, match, scope, recordHit);
389
- }
390
- else {
391
- searchTextIntentIndexed(svc, artifact.artifactId, query, match, scope, recordHit);
392
- }
414
+ indexedWindowFull = intent === "path"
415
+ ? searchPathIntentIndexed(svc, artifact.artifactId, query, match, scope, recordHit)
416
+ : searchTextIntentIndexed(svc, artifact.artifactId, query, match, scope, recordHit);
393
417
  svc.metrics.recordSearchIndexedHit();
394
418
  }
395
419
  catch (caughtError) {
@@ -424,6 +448,9 @@ export async function searchClassSource(svc, input) {
424
448
  svc.metrics.recordSearchIntentDuration(intent, Date.now() - intentStartedAt);
425
449
  const finalizedHits = accumulator.finalize();
426
450
  const page = finalizedHits.page;
451
+ if (indexedWindowFull && page.length < limit) {
452
+ searchWarnings.push(indexedCandidateWindowWarning(intent, indexedCandidateLimitForMatch(svc, match)));
453
+ }
427
454
  svc.metrics.recordSearchRowsReturned(page.length);
428
455
  const nextCursor = finalizedHits.nextCursorHit
429
456
  ? encodeSearchCursor(finalizedHits.nextCursorHit, cursorContext)
@@ -468,17 +495,19 @@ export function searchSymbolIntent(svc, artifactId, query, match, scope, regexPa
468
495
  }
469
496
  export function searchTextIntentIndexed(svc, artifactId, query, match, scope, onHit) {
470
497
  const candidateLimit = indexedCandidateLimitForMatch(svc, match);
498
+ const pathPrefix = indexedScopePathPrefix(scope);
471
499
  const indexed = svc.filesRepo.searchFileCandidates(artifactId, {
472
500
  query,
473
501
  match,
474
502
  limit: candidateLimit,
475
- mode: "text"
503
+ mode: "text",
504
+ ...(pathPrefix ? { pathPrefix } : {})
476
505
  });
477
506
  svc.metrics.recordSearchDbRoundtrip(indexed.dbRoundtrips);
478
507
  svc.metrics.recordSearchRowsScanned(indexed.scannedRows);
479
508
  if (indexed.items.length === 0) {
480
509
  svc.metrics.recordSearchIndexedZeroShortcircuit();
481
- return;
510
+ return false;
482
511
  }
483
512
  const globFilter = scope?.fileGlob ? buildGlobRegex(normalizePathStyle(scope.fileGlob)) : undefined;
484
513
  const candidatePaths = indexed.items
@@ -508,19 +537,22 @@ export function searchTextIntentIndexed(svc, artifactId, query, match, scope, on
508
537
  reasonCodes: ["content_match", `text_${match}`, "indexed"]
509
538
  });
510
539
  }
540
+ return isCandidateWindowFull(indexed);
511
541
  }
512
542
  export function searchPathIntentIndexed(svc, artifactId, query, match, scope, onHit) {
513
543
  const candidateLimit = indexedCandidateLimitForMatch(svc, match);
544
+ const pathPrefix = indexedScopePathPrefix(scope);
514
545
  const indexed = svc.filesRepo.searchFileCandidates(artifactId, {
515
546
  query,
516
547
  limit: candidateLimit,
517
- mode: "path"
548
+ mode: "path",
549
+ ...(pathPrefix ? { pathPrefix } : {})
518
550
  });
519
551
  svc.metrics.recordSearchDbRoundtrip(indexed.dbRoundtrips);
520
552
  svc.metrics.recordSearchRowsScanned(indexed.scannedRows);
521
553
  if (indexed.items.length === 0) {
522
554
  svc.metrics.recordSearchIndexedZeroShortcircuit();
523
- return;
555
+ return false;
524
556
  }
525
557
  const globFilter = scope?.fileGlob ? buildGlobRegex(normalizePathStyle(scope.fileGlob)) : undefined;
526
558
  const candidateRows = [];
@@ -551,6 +583,21 @@ export function searchPathIntentIndexed(svc, artifactId, query, match, scope, on
551
583
  reasonCodes: ["path_match", `path_${match}`, "indexed"]
552
584
  });
553
585
  }
586
+ return isCandidateWindowFull(indexed);
587
+ }
588
+ /**
589
+ * True when the index held more candidates than the window it returned: the
590
+ * repository reports that with a continuation cursor, which the single-window
591
+ * indexed search never follows.
592
+ */
593
+ function isCandidateWindowFull(indexed) {
594
+ return indexed.nextCursor !== undefined;
595
+ }
596
+ function indexedCandidateWindowWarning(intent, candidateLimit) {
597
+ const fullScan = intent === "text"
598
+ ? 'queryMode="literal" for a full substring scan'
599
+ : 'match="regex" for a full path scan';
600
+ return `indexed ${intent} search examined only its candidate window (the first ${candidateLimit} index candidates) and more matched the index, so results may be incomplete. Use ${fullScan}, or narrow the scope with packagePrefix or a fileGlob that starts with a literal directory.`;
554
601
  }
555
602
  const ASCII_RE = /^[\x00-\x7F]*$/;
556
603
  /** True when every code unit is ASCII, so SQLite LIKE folds case identically to JS. */