@adhisang/minecraft-modding-mcp 4.2.1 → 6.0.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 (144) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +5 -4
  3. package/dist/access-transformer-parser.d.ts +2 -1
  4. package/dist/access-transformer-parser.js +10 -1
  5. package/dist/access-widener-parser.d.ts +1 -0
  6. package/dist/access-widener-parser.js +6 -3
  7. package/dist/build-suggested-call.js +31 -10
  8. package/dist/cache-registry.d.ts +8 -0
  9. package/dist/cache-registry.js +50 -32
  10. package/dist/config.d.ts +1 -0
  11. package/dist/config.js +4 -0
  12. package/dist/decompiler/vineflower.js +8 -2
  13. package/dist/entry-tools/analyze-mod-service.d.ts +24 -24
  14. package/dist/entry-tools/analyze-mod-service.js +2 -1
  15. package/dist/entry-tools/analyze-symbol-service.d.ts +57 -22
  16. package/dist/entry-tools/analyze-symbol-service.js +139 -34
  17. package/dist/entry-tools/batch-class-members-service.d.ts +3 -1
  18. package/dist/entry-tools/batch-class-members-service.js +6 -6
  19. package/dist/entry-tools/batch-class-source-service.d.ts +3 -1
  20. package/dist/entry-tools/batch-class-source-service.js +5 -6
  21. package/dist/entry-tools/batch-mappings-service.d.ts +3 -1
  22. package/dist/entry-tools/batch-mappings-service.js +4 -5
  23. package/dist/entry-tools/batch-symbol-exists-service.d.ts +3 -1
  24. package/dist/entry-tools/batch-symbol-exists-service.js +4 -5
  25. package/dist/entry-tools/compare-minecraft-service.d.ts +10 -10
  26. package/dist/entry-tools/compare-minecraft-service.js +1 -1
  27. package/dist/entry-tools/entry-tool-schema.d.ts +1 -1
  28. package/dist/entry-tools/inspect-minecraft/handlers/artifact.js +1 -3
  29. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +4 -3
  30. package/dist/entry-tools/inspect-minecraft/handlers/class-overview.js +0 -5
  31. package/dist/entry-tools/inspect-minecraft/handlers/class-source.js +0 -1
  32. package/dist/entry-tools/inspect-minecraft/handlers/file.js +26 -2
  33. package/dist/entry-tools/inspect-minecraft/handlers/list-files.js +0 -1
  34. package/dist/entry-tools/inspect-minecraft/handlers/search.d.ts +8 -0
  35. package/dist/entry-tools/inspect-minecraft/handlers/search.js +13 -2
  36. package/dist/entry-tools/inspect-minecraft/handlers/versions.d.ts +1 -1
  37. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +3 -2
  38. package/dist/entry-tools/inspect-minecraft/internal.d.ts +90 -89
  39. package/dist/entry-tools/inspect-minecraft/internal.js +32 -14
  40. package/dist/entry-tools/inspect-minecraft-service.d.ts +218 -218
  41. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  42. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  43. package/dist/entry-tools/response-contract.d.ts +3 -1
  44. package/dist/entry-tools/response-contract.js +3 -2
  45. package/dist/entry-tools/validate-project/cases/mixin.js +2 -1
  46. package/dist/entry-tools/validate-project/cases/project-summary.js +5 -3
  47. package/dist/entry-tools/validate-project-service.d.ts +42 -36
  48. package/dist/entry-tools/validate-project-service.js +3 -1
  49. package/dist/entry-tools/verify-mixin-target-service.d.ts +30 -0
  50. package/dist/entry-tools/verify-mixin-target-service.js +120 -29
  51. package/dist/error-mapping.d.ts +51 -0
  52. package/dist/error-mapping.js +157 -2
  53. package/dist/index.js +147 -76
  54. package/dist/java-process.js +1 -1
  55. package/dist/json-rpc-framing.d.ts +4 -0
  56. package/dist/json-rpc-framing.js +23 -1
  57. package/dist/mapping/internal-types.d.ts +24 -0
  58. package/dist/mapping/loaders/tiny-maven.js +46 -20
  59. package/dist/mapping/lookup.d.ts +13 -2
  60. package/dist/mapping/lookup.js +60 -2
  61. package/dist/mapping/types.d.ts +6 -0
  62. package/dist/mapping-service.d.ts +26 -2
  63. package/dist/mapping-service.js +212 -55
  64. package/dist/mcp-helpers.d.ts +1 -0
  65. package/dist/mcp-helpers.js +25 -3
  66. package/dist/minecraft-explorer-service.js +24 -11
  67. package/dist/mixin/access-validators.js +33 -1
  68. package/dist/mixin/annotation-validators.js +73 -9
  69. package/dist/mixin/parsed-validator.js +3 -2
  70. package/dist/mixin-parser.js +119 -7
  71. package/dist/mod-decompile-service.d.ts +2 -0
  72. package/dist/mod-decompile-service.js +21 -3
  73. package/dist/mod-remap-service.d.ts +20 -1
  74. package/dist/mod-remap-service.js +101 -45
  75. package/dist/mojang-tiny-mapping-service.js +5 -2
  76. package/dist/nbt/java-nbt-codec.js +46 -16
  77. package/dist/nbt/json-patch.js +51 -12
  78. package/dist/nbt/typed-json.d.ts +1 -0
  79. package/dist/nbt/typed-json.js +12 -2
  80. package/dist/nbt/types.d.ts +2 -2
  81. package/dist/observability.d.ts +6 -0
  82. package/dist/observability.js +10 -0
  83. package/dist/page-cursor.d.ts +17 -0
  84. package/dist/page-cursor.js +0 -0
  85. package/dist/path-converter.js +10 -3
  86. package/dist/registry-service.d.ts +2 -0
  87. package/dist/registry-service.js +41 -2
  88. package/dist/repo-downloader.js +4 -3
  89. package/dist/resources.js +45 -9
  90. package/dist/response-utils.d.ts +29 -4
  91. package/dist/response-utils.js +95 -5
  92. package/dist/source/access-validate.js +12 -2
  93. package/dist/source/artifact-resolver.js +2 -2
  94. package/dist/source/class-source/members-builder.d.ts +44 -1
  95. package/dist/source/class-source/members-builder.js +51 -4
  96. package/dist/source/class-source/snippet-builder.d.ts +13 -0
  97. package/dist/source/class-source/snippet-builder.js +54 -4
  98. package/dist/source/class-source-helpers.d.ts +4 -4
  99. package/dist/source/class-source-helpers.js +77 -20
  100. package/dist/source/class-source.d.ts +19 -0
  101. package/dist/source/class-source.js +144 -40
  102. package/dist/source/file-access.js +3 -2
  103. package/dist/source/indexer.js +29 -9
  104. package/dist/source/lifecycle/mapping-helpers.js +28 -3
  105. package/dist/source/lifecycle/runtime-check.js +20 -6
  106. package/dist/source/search.d.ts +1 -1
  107. package/dist/source/search.js +117 -30
  108. package/dist/source/validate-mixin/pipeline/resolve.js +23 -3
  109. package/dist/source/validate-mixin.d.ts +2 -1
  110. package/dist/source/validate-mixin.js +45 -4
  111. package/dist/source/workspace-target.js +17 -10
  112. package/dist/source-jar-reader.d.ts +54 -1
  113. package/dist/source-jar-reader.js +121 -10
  114. package/dist/source-resolver.js +2 -2
  115. package/dist/source-service.d.ts +28 -7
  116. package/dist/source-service.js +9 -1
  117. package/dist/stdio-supervisor.d.ts +2 -2
  118. package/dist/stdio-supervisor.js +29 -9
  119. package/dist/storage/db.js +24 -1
  120. package/dist/storage/files-repo.d.ts +15 -0
  121. package/dist/storage/files-repo.js +67 -37
  122. package/dist/storage/symbols-repo.d.ts +15 -0
  123. package/dist/storage/symbols-repo.js +53 -19
  124. package/dist/symbols/symbol-extractor.js +35 -6
  125. package/dist/text-truncate.d.ts +7 -0
  126. package/dist/text-truncate.js +21 -0
  127. package/dist/tiny-remapper-resolver.js +10 -0
  128. package/dist/tool-contract-manifest.js +2 -2
  129. package/dist/tool-execution-gate.js +12 -1
  130. package/dist/tool-guidance.d.ts +6 -2
  131. package/dist/tool-guidance.js +63 -13
  132. package/dist/tool-schemas.d.ts +561 -496
  133. package/dist/tool-schemas.js +175 -139
  134. package/dist/types.d.ts +1 -0
  135. package/dist/version-diff-service.js +5 -0
  136. package/dist/version-service.d.ts +3 -0
  137. package/dist/version-service.js +18 -2
  138. package/dist/warning-details.d.ts +30 -0
  139. package/dist/warning-details.js +80 -0
  140. package/dist/workspace-mapping-service.js +9 -0
  141. package/docs/README-ja.md +2 -2
  142. package/docs/examples.md +26 -2
  143. package/docs/tool-reference.md +22 -17
  144. package/package.json +1 -1
@@ -25,6 +25,22 @@ function looksLikeClassSegment(name) {
25
25
  const trimmed = name.trim();
26
26
  return /^[A-Z_$]/.test(trimmed);
27
27
  }
28
+ /**
29
+ * Collapse N per-member remap failures into a single warning line. Preserves the
30
+ * original "Could not remap" / "Remap failed for" prefix so downstream classifiers
31
+ * (e.g. REMAP_WARNING_RE in validate-mixin, warningDetails coding) still recognise it,
32
+ * while bounding the per-member flood to one line plus a short sample of names.
33
+ */
34
+ function pushAggregatedRemapWarning(warnings, prefix, names, kind, sourceMapping, targetMapping) {
35
+ if (names.length === 0) {
36
+ return;
37
+ }
38
+ const unique = [...new Set(names)];
39
+ const sample = unique.slice(0, 3).join(", ");
40
+ const more = unique.length > 3 ? `, +${unique.length - 3} more` : "";
41
+ const plural = names.length === 1 ? "" : "s";
42
+ warnings.push(`${prefix} ${names.length} ${kind}${plural} from ${sourceMapping} to ${targetMapping} (${sample}${more}).`);
43
+ }
28
44
  export function rejectLifecycleClassLikeInput(svc, input) {
29
45
  void svc;
30
46
  if (!looksLikeClassSegment(input.methodName)) {
@@ -234,6 +250,13 @@ export async function remapSignatureMembers(svc, members, kind, version, sourceM
234
250
  "resolveMethodMappingExact" in svc.mappingService &&
235
251
  typeof svc.mappingService.resolveMethodMappingExact === "function";
236
252
  const memberEntries = [...memberKeyToRemapped.entries()];
253
+ // Collect per-member remap failures and emit a SINGLE aggregated warning each
254
+ // after the loop instead of one line per member. On namespace mismatches a large
255
+ // class can fail to remap every one of its ~150 members, which previously flooded
256
+ // both warnings[] and the 1:1 warningDetails[] with hundreds of near-identical
257
+ // lines (the dominant token sink for these responses).
258
+ const unremappedNames = [];
259
+ const remapErrorNames = [];
237
260
  await Promise.all(memberEntries.map(async ([key, _sourceName]) => {
238
261
  const [ownerFqn, name, descriptor] = key.split("\0");
239
262
  try {
@@ -299,20 +322,22 @@ export async function remapSignatureMembers(svc, members, kind, version, sourceM
299
322
  }
300
323
  }
301
324
  else {
302
- warnings.push(`Could not remap ${kind} "${name}" from ${sourceMapping} to ${targetMapping}.`);
325
+ unremappedNames.push(name);
303
326
  failedNames.add(name);
304
327
  }
305
328
  }
306
329
  else {
307
- warnings.push(`Could not remap ${kind} "${name}" from ${sourceMapping} to ${targetMapping}.`);
330
+ unremappedNames.push(name);
308
331
  failedNames.add(name);
309
332
  }
310
333
  }
311
334
  catch {
312
- warnings.push(`Remap failed for ${kind} "${name}" from ${sourceMapping} to ${targetMapping}.`);
335
+ remapErrorNames.push(name);
313
336
  failedNames.add(name);
314
337
  }
315
338
  }));
339
+ pushAggregatedRemapWarning(warnings, "Could not remap", unremappedNames, kind, sourceMapping, targetMapping);
340
+ pushAggregatedRemapWarning(warnings, "Remap failed for", remapErrorNames, kind, sourceMapping, targetMapping);
316
341
  const isField = kind === "field";
317
342
  return {
318
343
  members: members.map((member) => {
@@ -1,3 +1,4 @@
1
+ import { ERROR_CODES, isAppError } from "../../errors.js";
1
2
  export async function checkSymbolExistsInUnobfuscatedRuntime(svc, input, fallbackBase) {
2
3
  const version = input.version.trim();
3
4
  const name = input.name.trim();
@@ -50,16 +51,25 @@ export async function checkSymbolExistsInUnobfuscatedRuntime(svc, input, fallbac
50
51
  signature = await svc.explorerService.getSignature({
51
52
  fqn: targetClass,
52
53
  jarPath,
53
- access: "all"
54
+ access: "all",
55
+ // Method existence is asked relative to the owner type, so inherited methods count:
56
+ // `level.getGameTime()` exists on Level even though it is declared on a supertype.
57
+ // Scoped to methods only: fields already resolved correctly without an inheritance
58
+ // walk, and including inherited fields would let a shadowed field name match twice
59
+ // and flip a previously-resolved field to "ambiguous".
60
+ includeInherited: input.kind === "method"
54
61
  });
55
62
  }
56
- catch {
63
+ catch (error) {
64
+ const classMissing = isAppError(error) && error.code === ERROR_CODES.CLASS_NOT_FOUND;
57
65
  return {
58
66
  ...fallbackBase,
59
67
  querySymbol,
60
68
  warnings: [
61
69
  ...fallbackBase.warnings,
62
- `Version ${version} is unobfuscated; runtime bytecode lookup could not load class "${targetClass}".`
70
+ classMissing
71
+ ? `Class "${targetClass}" was not found in the Minecraft ${version} runtime jar; it does not exist (or is not in this jar).`
72
+ : `Version ${version} is unobfuscated; runtime bytecode lookup could not load class "${targetClass}".`
63
73
  ]
64
74
  };
65
75
  }
@@ -114,9 +124,13 @@ export async function checkSymbolExistsInUnobfuscatedRuntime(svc, input, fallbac
114
124
  });
115
125
  }
116
126
  const methodCandidates = signature.methods.filter((method) => method.name === name);
117
- if (input.signatureMode === "name-only") {
118
- if (methodCandidates.length !== 1) {
119
- return buildUnresolved(methodCandidates.length > 1 ? "ambiguous" : "not_found");
127
+ const signatureMode = input.signatureMode ?? "name-only";
128
+ if (signatureMode === "name-only") {
129
+ // Existence semantics: any overload with this name means the method exists. Multiple
130
+ // overloads are not "ambiguous" for a name-only existence check — only zero matches
131
+ // is not_found. (Use signatureMode: "exact" with a descriptor to pin one overload.)
132
+ if (methodCandidates.length === 0) {
133
+ return buildUnresolved("not_found");
120
134
  }
121
135
  return buildResolved({
122
136
  kind: "method",
@@ -44,7 +44,7 @@ export declare function searchClassSource(svc: SourceService, input: SearchClass
44
44
  export declare function searchSymbolIntent(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined, onHit: (hit: SearchSourceHit) => void): void;
45
45
  export declare function searchTextIntentIndexed(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, onHit: (hit: SearchSourceHit) => void): void;
46
46
  export declare function searchPathIntentIndexed(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, onHit: (hit: SearchSourceHit) => void): void;
47
- export declare function searchTextIntent(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined, onHit: (hit: SearchSourceHit) => void): void;
47
+ 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
48
  export declare function searchPathIntent(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined, onHit: (hit: SearchSourceHit) => void): void;
49
49
  export declare function findSymbolHits(svc: SourceService, artifactId: string, query: string, match: SearchMatch, scope: SearchScope | undefined, regexPattern: RegExp | undefined): IndexedSymbolHit[];
50
50
  export declare function indexedCandidateLimit(svc: SourceService): number;
@@ -65,11 +65,13 @@ export function buildGlobRegex(pattern) {
65
65
  while (i < pattern.length) {
66
66
  const ch = pattern[i];
67
67
  if (ch === "*" && pattern[i + 1] === "*") {
68
- result += ".*";
69
- i += 2;
70
- if (pattern[i] === "/") {
71
- result += "(?:/)?";
72
- i += 1;
68
+ if (pattern[i + 2] === "/") {
69
+ result += "(?:.*/)?";
70
+ i += 3;
71
+ }
72
+ else {
73
+ result += ".*";
74
+ i += 2;
73
75
  }
74
76
  }
75
77
  else if (ch === "*") {
@@ -182,6 +184,11 @@ export function findContentMatchIndex(content, query, match, pattern) {
182
184
  }
183
185
  const normalizedContent = toLower(content);
184
186
  const normalizedQuery = toLower(query);
187
+ // Both "contains" and "prefix" map to a substring search here. For free-text
188
+ // content, `prefix` is intentionally treated as "contains": file bodies are
189
+ // searched grep-style and the default match for intent=text resolves to
190
+ // "prefix" (see normalizeMatch), so anchoring at the start of the file would
191
+ // yield almost no hits. Symbol/path prefix anchoring lives in findMatchIndex.
185
192
  return normalizedContent.indexOf(normalizedQuery);
186
193
  }
187
194
  export function scoreSymbolMatch(match, index, symbolKind) {
@@ -296,6 +303,9 @@ export async function searchClassSource(svc, input) {
296
303
  searchWarnings.push(`queryNamespace=${input.queryNamespace}: translation failed (${caughtError instanceof Error ? caughtError.message : String(caughtError)}); running literal search instead.`);
297
304
  }
298
305
  }
306
+ else if (intent === "symbol") {
307
+ searchWarnings.push(`queryNamespace=${input.queryNamespace} requires a fully-qualified class name for translation; running literal search in ${artifactMapping} instead. Resolve "${originalQuery}" to an FQCN with find-class or find-mapping first.`);
308
+ }
299
309
  else if (intent === "text" || intent === "path") {
300
310
  searchWarnings.push(`queryNamespace=${input.queryNamespace} has no effect when intent="${intent}" — ${intent} search is a literal match against the artifact's ${artifactMapping} index. Use intent="symbol" for namespace translation.`);
301
311
  }
@@ -336,6 +346,10 @@ export async function searchClassSource(svc, input) {
336
346
  });
337
347
  const decodedCursor = decodeSearchCursor(input.cursor);
338
348
  const cursor = decodedCursor?.contextKey === cursorContext ? decodedCursor : undefined;
349
+ // A provided cursor that did not decode, or whose context key does not match
350
+ // this query, is silently dropped and the scan restarts from page one. Flag
351
+ // it so callers do not assume they are continuing a previous page.
352
+ const cursorIgnored = input.cursor != null && cursor == null;
339
353
  const accumulator = createSearchHitAccumulator(limit, cursor);
340
354
  const indexedSearchEnabled = svc.config.indexedSearchEnabled !== false;
341
355
  if (match === "regex") {
@@ -345,13 +359,16 @@ export async function searchClassSource(svc, input) {
345
359
  const recordHit = (hit) => {
346
360
  accumulator.add(hit);
347
361
  };
362
+ const recordWarning = (warning) => {
363
+ searchWarnings.push(warning);
364
+ };
348
365
  const tokenOnlyTextIntent = intent === "text" && queryMode === "token";
349
366
  if (intent === "symbol") {
350
367
  searchSymbolIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
351
368
  }
352
369
  else if (queryMode === "literal" && intent === "text") {
353
370
  svc.metrics.recordSearchFallback();
354
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
371
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
355
372
  }
356
373
  else if (!indexedSearchEnabled) {
357
374
  svc.metrics.recordIndexedDisabled();
@@ -361,7 +378,7 @@ export async function searchClassSource(svc, input) {
361
378
  searchPathIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
362
379
  }
363
380
  else {
364
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
381
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
365
382
  }
366
383
  }
367
384
  }
@@ -388,7 +405,7 @@ export async function searchClassSource(svc, input) {
388
405
  searchPathIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
389
406
  }
390
407
  else {
391
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
408
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
392
409
  }
393
410
  }
394
411
  }
@@ -400,7 +417,7 @@ export async function searchClassSource(svc, input) {
400
417
  searchPathIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
401
418
  }
402
419
  else {
403
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
420
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
404
421
  }
405
422
  }
406
423
  }
@@ -415,6 +432,7 @@ export async function searchClassSource(svc, input) {
415
432
  return {
416
433
  hits: page,
417
434
  nextCursor,
435
+ ...(cursorIgnored ? { cursorIgnored: true } : {}),
418
436
  mappingApplied: artifact.mappingApplied ?? "obfuscated",
419
437
  returnedNamespace: artifact.mappingApplied ?? "obfuscated",
420
438
  artifactContents: svc.buildArtifactContentsSummary({
@@ -534,39 +552,98 @@ export function searchPathIntentIndexed(svc, artifactId, query, match, scope, on
534
552
  });
535
553
  }
536
554
  }
537
- export function searchTextIntent(svc, artifactId, query, match, scope, regexPattern, onHit) {
538
- const pageSize = Math.max(1, svc.config.searchScanPageSize ?? 250);
555
+ const ASCII_RE = /^[\x00-\x7F]*$/;
556
+ /** True when every code unit is ASCII, so SQLite LIKE folds case identically to JS. */
557
+ function isAsciiNeedle(query) {
558
+ return ASCII_RE.test(query);
559
+ }
560
+ function searchScanBudgetWarning(byteBudget) {
561
+ return `search scan budget of ${byteBudget} bytes reached; results may be incomplete. Narrow the query or scope (packagePrefix / fileGlob) to scan fewer files.`;
562
+ }
563
+ export function searchTextIntent(svc, artifactId, query, match, scope, regexPattern, onHit, onWarning) {
539
564
  const glob = scope?.fileGlob ? buildGlobRegex(normalizePathStyle(scope.fileGlob)) : undefined;
565
+ const byteBudget = Math.max(1, svc.config.searchScanMaxBytes ?? Number.MAX_SAFE_INTEGER);
566
+ let scannedBytes = 0;
567
+ let truncated = false;
568
+ const passesScope = (filePath) => checkPackagePrefix(filePath, scope?.packagePrefix) && (!glob || glob.test(filePath));
569
+ const emitIfMatch = (filePath, content) => {
570
+ const contentIndex = match === "regex"
571
+ ? matchRegexIndex(content, regexPattern)
572
+ : findContentMatchIndex(content, query, match);
573
+ if (contentIndex < 0) {
574
+ return;
575
+ }
576
+ onHit({
577
+ filePath,
578
+ score: scoreTextMatch(match, contentIndex),
579
+ matchedIn: "content",
580
+ reasonCodes: ["content_match", `text_${match}`]
581
+ });
582
+ };
583
+ // FAST PATH: an ASCII, non-regex needle narrows candidates via a content LIKE
584
+ // prefilter (a proven superset of the JS match), so only matching files are
585
+ // hydrated instead of scanning the whole corpus. The unchanged JS post-verify in
586
+ // emitIfMatch removes any over-included rows.
587
+ if (match !== "regex" && isAsciiNeedle(query)) {
588
+ const candidateLimit = indexedCandidateLimitForMatch(svc, match);
589
+ const { filePaths, scannedRows } = svc.filesRepo.searchContentLikeCandidatePaths(artifactId, query, candidateLimit);
590
+ svc.metrics.recordSearchDbRoundtrip();
591
+ svc.metrics.recordSearchRowsScanned(scannedRows);
592
+ // The prefilter caps candidates at candidateLimit ordered by file_path. Below the
593
+ // cap the candidate set is exhaustive, so the JS post-verify yields exactly the old
594
+ // full-scan hits. AT the cap more matches may exist beyond the path-ordered cut, and
595
+ // dropping them would change the scored top-K page and totalApprox/cursor — so fall
596
+ // through to the exhaustive paged scan below to preserve identical results.
597
+ if (scannedRows < candidateLimit) {
598
+ svc.metrics.recordSearchLikePrefilter();
599
+ const scopedPaths = filePaths.filter(passesScope);
600
+ const rows = svc.filesRepo.getFileContentsByPaths(artifactId, scopedPaths);
601
+ svc.metrics.recordSearchDbRoundtrip();
602
+ svc.metrics.recordSearchRowsScanned(rows.length);
603
+ for (const row of rows) {
604
+ if (scannedBytes >= byteBudget) {
605
+ truncated = true;
606
+ break;
607
+ }
608
+ scannedBytes += row.contentBytes;
609
+ emitIfMatch(row.filePath, row.content);
610
+ }
611
+ if (truncated) {
612
+ svc.metrics.recordSearchScanTruncated();
613
+ onWarning?.(searchScanBudgetWarning(byteBudget));
614
+ }
615
+ return;
616
+ }
617
+ // Cap overflow: fall through to the exhaustive scan (rare; only common-token queries).
618
+ }
619
+ // SLOW PATH: regex (cannot be pushed to SQL) or non-ASCII needles (SQLite LIKE
620
+ // would fold case differently than JS) scan the corpus page by page.
621
+ const pageSize = Math.max(1, svc.config.searchScanPageSize ?? 250);
540
622
  let cursor = undefined;
541
- while (true) {
623
+ outer: while (true) {
542
624
  const page = svc.filesRepo.listFileRows(artifactId, { limit: pageSize, cursor });
543
625
  svc.metrics.recordSearchDbRoundtrip();
544
626
  svc.metrics.recordSearchRowsScanned(page.items.length);
545
627
  for (const row of page.items) {
546
- if (!checkPackagePrefix(row.filePath, scope?.packagePrefix)) {
547
- continue;
548
- }
549
- if (glob && !glob.test(row.filePath)) {
628
+ if (!passesScope(row.filePath)) {
550
629
  continue;
551
630
  }
552
- const contentIndex = match === "regex"
553
- ? matchRegexIndex(row.content, regexPattern)
554
- : findContentMatchIndex(row.content, query, match);
555
- if (contentIndex < 0) {
556
- continue;
631
+ if (scannedBytes >= byteBudget) {
632
+ truncated = true;
633
+ break outer;
557
634
  }
558
- onHit({
559
- filePath: row.filePath,
560
- score: scoreTextMatch(match, contentIndex),
561
- matchedIn: "content",
562
- reasonCodes: ["content_match", `text_${match}`]
563
- });
635
+ scannedBytes += row.contentBytes;
636
+ emitIfMatch(row.filePath, row.content);
564
637
  }
565
638
  if (!page.nextCursor) {
566
639
  break;
567
640
  }
568
641
  cursor = page.nextCursor;
569
642
  }
643
+ if (truncated) {
644
+ svc.metrics.recordSearchScanTruncated();
645
+ onWarning?.(searchScanBudgetWarning(byteBudget));
646
+ }
570
647
  }
571
648
  export function searchPathIntent(svc, artifactId, query, match, scope, regexPattern, onHit) {
572
649
  const pageSize = Math.max(1, svc.config.searchScanPageSize ?? 250);
@@ -604,7 +681,9 @@ export function searchPathIntent(svc, artifactId, query, match, scope, regexPatt
604
681
  }
605
682
  export function findSymbolHits(svc, artifactId, query, match, scope, regexPattern) {
606
683
  if (match !== "regex") {
607
- const filePathLike = scope?.fileGlob ? globToSqlLike(normalizePathStyle(scope.fileGlob)) : undefined;
684
+ const filePathLike = scope?.fileGlob && !scope.fileGlob.includes("**")
685
+ ? globToSqlLike(normalizePathStyle(scope.fileGlob))
686
+ : undefined;
608
687
  const scoped = svc.symbolsRepo.findScopedSymbols({
609
688
  artifactId,
610
689
  query,
@@ -616,8 +695,12 @@ export function findSymbolHits(svc, artifactId, query, match, scope, regexPatter
616
695
  });
617
696
  svc.metrics.recordSearchDbRoundtrip();
618
697
  svc.metrics.recordSearchRowsScanned(scoped.items.length);
698
+ const globFilter = scope?.fileGlob ? buildGlobRegex(normalizePathStyle(scope.fileGlob)) : undefined;
619
699
  const result = [];
620
700
  for (const symbol of scoped.items) {
701
+ if (globFilter && !globFilter.test(symbol.filePath)) {
702
+ continue;
703
+ }
621
704
  if (!isSymbolKind(symbol.symbolKind)) {
622
705
  continue;
623
706
  }
@@ -633,12 +716,15 @@ export function findSymbolHits(svc, artifactId, query, match, scope, regexPatter
633
716
  }
634
717
  return result;
635
718
  }
636
- const candidates = svc.symbolsRepo.listSymbolsForArtifact(artifactId, scope?.symbolKind);
719
+ // Stream rows instead of materializing every symbol: regex scans on large
720
+ // artifacts only keep matching rows in memory this way.
721
+ const candidates = svc.symbolsRepo.iterateSymbolsForArtifact(artifactId, scope?.symbolKind);
637
722
  svc.metrics.recordSearchDbRoundtrip();
638
- svc.metrics.recordSearchRowsScanned(candidates.length);
723
+ let rowsScanned = 0;
639
724
  const result = [];
640
725
  const glob = scope?.fileGlob ? buildGlobRegex(normalizePathStyle(scope.fileGlob)) : undefined;
641
726
  for (const symbol of candidates) {
727
+ rowsScanned += 1;
642
728
  if (!checkPackagePrefix(symbol.filePath, scope?.packagePrefix)) {
643
729
  continue;
644
730
  }
@@ -660,6 +746,7 @@ export function findSymbolHits(svc, artifactId, query, match, scope, regexPatter
660
746
  matchIndex: index
661
747
  });
662
748
  }
749
+ svc.metrics.recordSearchRowsScanned(rowsScanned);
663
750
  return result;
664
751
  }
665
752
  export function indexedCandidateLimit(svc) {
@@ -40,10 +40,22 @@ export async function runResolveStage(svc, ctx) {
40
40
  scope: ctx.input.scope,
41
41
  preferProjectVersion: false
42
42
  });
43
- ctx.jarPath = ctx.resolvedArtifact.binaryJarPath ?? (await svc.versionService.resolveVersionJar(ctx.version)).jarPath;
44
43
  ctx.warnings.push(...ctx.resolvedArtifact.warnings);
45
- ctx.mappingApplied = ctx.resolvedArtifact.mappingApplied;
46
- ctx.signatureLookupMapping = ctx.resolvedArtifact.mappingApplied;
44
+ if (ctx.resolvedArtifact.binaryJarPath) {
45
+ ctx.jarPath = ctx.resolvedArtifact.binaryJarPath;
46
+ ctx.mappingApplied = ctx.resolvedArtifact.mappingApplied;
47
+ ctx.signatureLookupMapping = ctx.resolvedArtifact.mappingApplied;
48
+ }
49
+ else {
50
+ ctx.jarPath = (await svc.versionService.resolveVersionJar(ctx.version)).jarPath;
51
+ ctx.signatureLookupMapping = "obfuscated";
52
+ ctx.scopeFallback = {
53
+ requested: ctx.input.scope,
54
+ applied: "vanilla",
55
+ reason: "Resolved artifact had no binary jar (sources only); falling back to vanilla client jar."
56
+ };
57
+ ctx.warnings.push(`Scope "${ctx.input.scope}" resolved without a binary jar; falling back to vanilla client jar.`);
58
+ }
47
59
  if (ctx.resolvedArtifact.version) {
48
60
  ctx.version = ctx.resolvedArtifact.version;
49
61
  }
@@ -59,6 +71,14 @@ export async function runResolveStage(svc, ctx) {
59
71
  }
60
72
  }
61
73
  else {
74
+ if (ctx.input.scope && ctx.input.scope !== "vanilla") {
75
+ ctx.scopeFallback = {
76
+ requested: ctx.input.scope,
77
+ applied: "vanilla",
78
+ reason: "projectPath required for merged/loader scope; falling back to vanilla client jar."
79
+ };
80
+ ctx.warnings.push(`Scope "${ctx.input.scope}" requires projectPath; falling back to vanilla client jar.`);
81
+ }
62
82
  ctx.jarPath = (await svc.versionService.resolveVersionJar(ctx.version)).jarPath;
63
83
  }
64
84
  if (ctx.jarPath.includes("-sources.jar")) {
@@ -4,7 +4,8 @@ import type { ValidateMixinInput, ValidateMixinOptions, ValidateMixinOutput } fr
4
4
  import { type StageEmitter } from "../stage-emitter.js";
5
5
  import type { MappingSourcePriority } from "../types.js";
6
6
  import type { FindMappingOutput as MappingFindMappingOutput } from "../mapping-service.js";
7
- export type ValidateMixinSingleInput = Omit<ValidateMixinInput, "input"> & {
7
+ export type ValidateMixinSingleInput = Omit<ValidateMixinInput, "input" | "version"> & {
8
+ version: string;
8
9
  source?: string;
9
10
  sourcePath?: string;
10
11
  batchCaches?: {
@@ -159,7 +159,43 @@ export async function validateMixin(svc, input, options = {}) {
159
159
  throw annotateValidateMixinError(err, "input-validation");
160
160
  }
161
161
  }
162
- async function runValidateMixinDispatcher(svc, input, options = {}) {
162
+ /**
163
+ * Resolve the Minecraft version for a validate-mixin request. Uses the explicit
164
+ * `version` when present; otherwise detects it from gradle.properties for
165
+ * project mode or when preferProjectVersion is set with a projectPath. Throws an
166
+ * actionable INVALID_INPUT error when no version can be determined.
167
+ */
168
+ async function resolveRequestedMinecraftVersion(svc, input) {
169
+ const explicit = input.version?.trim();
170
+ if (explicit) {
171
+ return explicit;
172
+ }
173
+ const projectPathCandidate = input.projectPath ?? (input.input.mode === "project" ? input.input.path : undefined);
174
+ if (projectPathCandidate) {
175
+ const normalizedProjectPath = resolveMixinInputPath(projectPathCandidate, "path");
176
+ const detected = await svc.workspaceMappingService.detectProjectMinecraftVersion(normalizedProjectPath);
177
+ if (detected?.trim()) {
178
+ return detected.trim();
179
+ }
180
+ }
181
+ throw createError({
182
+ code: ERROR_CODES.INVALID_INPUT,
183
+ message: "Could not detect a Minecraft version from the project. Pass version explicitly, or ensure gradle.properties declares minecraft_version.",
184
+ details: {
185
+ failedStage: "input-validation",
186
+ nextAction: 'Pass version (e.g. "1.21.10"), or set preferProjectVersion=true with a projectPath whose gradle.properties contains minecraft_version.'
187
+ }
188
+ });
189
+ }
190
+ async function runValidateMixinDispatcher(svc, rawInput, options = {}) {
191
+ const requestedVersion = await resolveRequestedMinecraftVersion(svc, rawInput);
192
+ // Service-level default so direct/internal/entry-tool callers (not just the MCP
193
+ // wire boundary, where Zod applies it) get the lean summary-first report shape.
194
+ const input = {
195
+ ...rawInput,
196
+ version: requestedVersion,
197
+ reportMode: rawInput.reportMode ?? "summary-first"
198
+ };
163
199
  const { input: sourceInput, ...sharedInput } = input;
164
200
  const mode = sourceInput.mode;
165
201
  const stageEmitter = options.stageEmitter ?? NOOP_STAGE_EMITTER;
@@ -495,7 +531,9 @@ async function finalizeValidateMixinPipeline(svc, ctx) {
495
531
  result.aggregatedWarnings = undefined;
496
532
  result.toolHealth = undefined;
497
533
  result.confidenceBreakdown = undefined;
498
- if (result.provenance) {
534
+ // resolutionTrace only exists when explain=true; keep it then so explain still
535
+ // delivers the per-issue trace even under the compact report shape.
536
+ if (result.provenance && input.explain !== true) {
499
537
  result.provenance.resolutionTrace = undefined;
500
538
  }
501
539
  }
@@ -676,6 +714,9 @@ function applyValidateMixinOutputCompaction(output, input) {
676
714
  ? true
677
715
  : warningCandidates.every((entry) => sameStringArray(entry, warningCandidates[0]));
678
716
  if (input.reportMode === "summary-first") {
717
+ // explain=true keeps the per-result heavy detail (resolvedMembers/toolHealth)
718
+ // even under summary-first, so the diagnostic opt-in still works.
719
+ const keepHeavyDetail = input.explain === true;
679
720
  nextOutput = {
680
721
  ...nextOutput,
681
722
  results: nextOutput.results.map((entry) => (entry.result
@@ -686,8 +727,8 @@ function applyValidateMixinOutputCompaction(output, input) {
686
727
  warnings: canHoistWarnings ? [] : entry.result.warnings,
687
728
  structuredWarnings: undefined,
688
729
  aggregatedWarnings: undefined,
689
- resolvedMembers: undefined,
690
- toolHealth: undefined,
730
+ resolvedMembers: keepHeavyDetail ? entry.result.resolvedMembers : undefined,
731
+ toolHealth: keepHeavyDetail ? entry.result.toolHealth : undefined,
691
732
  confidenceBreakdown: undefined,
692
733
  provenance: canHoistProvenance ? undefined : entry.result.provenance
693
734
  }
@@ -37,6 +37,7 @@ export async function loadOrDetectWorkspaceContext(svc, projectPath) {
37
37
  value: loaderResult.loader
38
38
  });
39
39
  }
40
+ const latest = svc.workspaceContextCache.read(projectPath);
40
41
  const ctx = {
41
42
  projectPath,
42
43
  minecraftVersion,
@@ -44,7 +45,7 @@ export async function loadOrDetectWorkspaceContext(svc, projectPath) {
44
45
  loader: loaderResult?.resolved ? loaderResult.loader : undefined,
45
46
  detectedAt: Date.now(),
46
47
  evidence,
47
- dependencyVersions: cached?.dependencyVersions ?? new Map(),
48
+ dependencyVersions: latest?.dependencyVersions ?? cached?.dependencyVersions ?? new Map(),
48
49
  partial: false
49
50
  };
50
51
  svc.workspaceContextCache.write(ctx);
@@ -256,15 +257,21 @@ export async function synthesizeDependencyTarget(svc, input, dep) {
256
257
  nextAction,
257
258
  ...buildSuggestedCall({
258
259
  tool: "resolve-artifact",
259
- params: {
260
- target: {
261
- kind: "dependency",
262
- group,
263
- name,
264
- version: "<your-version>"
265
- },
266
- projectPath
267
- }
260
+ params: undefined,
261
+ examples: [
262
+ {
263
+ params: {
264
+ target: {
265
+ kind: "dependency",
266
+ group,
267
+ name,
268
+ version: "<your-version>"
269
+ },
270
+ projectPath
271
+ },
272
+ reason: "Replace <your-version> with the dependency version, or declare it in gradle.properties."
273
+ }
274
+ ]
268
275
  })
269
276
  }
270
277
  });
@@ -1,3 +1,20 @@
1
+ import type { Readable } from "node:stream";
2
+ export declare function __getZipOpenCount(): number;
3
+ export declare function __resetZipOpenCount(): void;
4
+ interface ZipEntry {
5
+ fileName: string;
6
+ }
7
+ export interface ZipFile {
8
+ readEntry(): void;
9
+ close(): void;
10
+ once(event: "entry", listener: (entry: ZipEntry) => void): this;
11
+ once(event: "end", listener: () => void): this;
12
+ once(event: "error", listener: (error: Error) => void): this;
13
+ removeListener(event: "entry", listener: (entry: ZipEntry) => void): this;
14
+ removeListener(event: "end", listener: () => void): this;
15
+ removeListener(event: "error", listener: (error: Error) => void): this;
16
+ openReadStream(entry: ZipEntry, callback: (error: Error | null, stream: Readable | null) => void): void;
17
+ }
1
18
  export interface JavaEntryText {
2
19
  filePath: string;
3
20
  content: string;
@@ -12,6 +29,7 @@ export interface CollectMatchedJarEntriesOptions {
12
29
  continueOnError?: boolean;
13
30
  }
14
31
  export declare function hasJavaSourceExtension(entryPath: string): boolean;
32
+ export declare function openZipFile(jarPath: string): Promise<ZipFile>;
15
33
  export declare class EntryTooLargeError extends Error {
16
34
  constructor(entryPath: string, jarPath: string, maxBytes: number);
17
35
  }
@@ -20,10 +38,45 @@ export declare function listJavaEntries(jarPath: string): Promise<string[]>;
20
38
  export declare function hasAnyJarEntry(jarPath: string, predicate: (entryPath: string) => boolean): Promise<boolean>;
21
39
  export declare function readJarEntryAsUtf8(jarPath: string, entryPath: string): Promise<string>;
22
40
  export declare function readJarEntryAsBuffer(jarPath: string, entryPath: string): Promise<Buffer>;
41
+ export interface JarEntryReader {
42
+ getEntryBuffer(entryPath: string): Promise<Buffer>;
43
+ close(): void;
44
+ }
45
+ /**
46
+ * Opens a jar ONCE, drains its central directory into a name->entry index, and
47
+ * serves repeated entry reads via O(1) lookup + openReadStream. Use this when a
48
+ * single logical operation reads many entries from the same jar (e.g. walking a
49
+ * class hierarchy) instead of calling {@link readJarEntryAsBuffer} per entry,
50
+ * which re-opens and re-scans the jar each time. Callers MUST close() the reader
51
+ * (in a finally) to release the file descriptor. getEntryBuffer throws the same
52
+ * INVALID_INPUT (unsafe path) / SOURCE_NOT_FOUND (missing entry) errors as
53
+ * readJarEntryAsBuffer; duplicate entry names resolve to the first occurrence.
54
+ */
55
+ export declare function createJarEntryReader(jarPath: string): Promise<JarEntryReader>;
23
56
  export declare function collectMatchedJarEntriesAsUtf8(jarPath: string, predicate: (entryPath: string) => boolean, options?: CollectMatchedJarEntriesOptions): Promise<JarEntryText[]>;
57
+ export interface JarEntryBuffer {
58
+ filePath: string;
59
+ content: Buffer;
60
+ }
61
+ /**
62
+ * Seam for injecting the jar-open primitive so callers/tests can count opens.
63
+ * Defaults to the module-level {@link openZipFile}.
64
+ */
65
+ export interface JarReaderOpenDeps {
66
+ openZipFile?: (jarPath: string) => Promise<ZipFile>;
67
+ }
68
+ /**
69
+ * Raw-buffer counterpart of {@link collectMatchedJarEntriesAsUtf8}: opens the jar
70
+ * ONCE and returns the matched entries as raw Buffers (no UTF-8 decode), so binary
71
+ * inputs like `.class` bytes survive intact. Used to avoid re-opening the jar per
72
+ * sampled entry. Mirrors the sibling's maxBytes/maxEntries/continueOnError semantics
73
+ * and its finally-close, but takes an injectable open seam for open-count assertions.
74
+ */
75
+ export declare function collectMatchedJarEntriesAsBuffers(jarPath: string, predicate: (entryPath: string) => boolean, options?: CollectMatchedJarEntriesOptions, deps?: JarReaderOpenDeps): Promise<JarEntryBuffer[]>;
24
76
  export declare function iterateJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): AsyncGenerator<JavaEntryText>;
25
77
  export declare function readAllJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): Promise<JavaEntryText[]>;
26
- export declare function detectFabricLikeInputNamespace(inputJar: string): Promise<{
78
+ export declare function detectFabricLikeInputNamespace(inputJar: string, deps?: JarReaderOpenDeps): Promise<{
27
79
  fromNamespace: "intermediary" | "mojang";
28
80
  warnings: string[];
29
81
  }>;
82
+ export {};