@adhisang/minecraft-modding-mcp 4.2.1 → 5.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 (114) hide show
  1. package/CHANGELOG.md +33 -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/build-suggested-call.js +31 -10
  6. package/dist/cache-registry.d.ts +8 -0
  7. package/dist/cache-registry.js +15 -1
  8. package/dist/config.d.ts +1 -0
  9. package/dist/config.js +4 -0
  10. package/dist/entry-tools/analyze-mod-service.d.ts +24 -24
  11. package/dist/entry-tools/analyze-symbol-service.d.ts +57 -22
  12. package/dist/entry-tools/analyze-symbol-service.js +124 -28
  13. package/dist/entry-tools/batch-class-members-service.d.ts +3 -1
  14. package/dist/entry-tools/batch-class-members-service.js +6 -6
  15. package/dist/entry-tools/batch-class-source-service.d.ts +3 -1
  16. package/dist/entry-tools/batch-class-source-service.js +5 -6
  17. package/dist/entry-tools/batch-mappings-service.d.ts +3 -1
  18. package/dist/entry-tools/batch-mappings-service.js +4 -5
  19. package/dist/entry-tools/batch-symbol-exists-service.d.ts +3 -1
  20. package/dist/entry-tools/batch-symbol-exists-service.js +4 -5
  21. package/dist/entry-tools/compare-minecraft-service.d.ts +10 -10
  22. package/dist/entry-tools/entry-tool-schema.d.ts +1 -1
  23. package/dist/entry-tools/inspect-minecraft/handlers/artifact.js +1 -3
  24. package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +2 -2
  25. package/dist/entry-tools/inspect-minecraft/handlers/class-overview.js +0 -5
  26. package/dist/entry-tools/inspect-minecraft/handlers/class-source.js +0 -1
  27. package/dist/entry-tools/inspect-minecraft/handlers/file.js +0 -1
  28. package/dist/entry-tools/inspect-minecraft/handlers/list-files.js +0 -1
  29. package/dist/entry-tools/inspect-minecraft/handlers/search.d.ts +8 -0
  30. package/dist/entry-tools/inspect-minecraft/handlers/search.js +13 -2
  31. package/dist/entry-tools/inspect-minecraft/handlers/versions.d.ts +1 -1
  32. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +1 -1
  33. package/dist/entry-tools/inspect-minecraft/internal.d.ts +90 -89
  34. package/dist/entry-tools/inspect-minecraft/internal.js +30 -14
  35. package/dist/entry-tools/inspect-minecraft-service.d.ts +218 -218
  36. package/dist/entry-tools/inspect-minecraft-service.js +1 -1
  37. package/dist/entry-tools/manage-cache-service.d.ts +4 -4
  38. package/dist/entry-tools/response-contract.d.ts +1 -1
  39. package/dist/entry-tools/response-contract.js +1 -0
  40. package/dist/entry-tools/validate-project/cases/mixin.js +2 -1
  41. package/dist/entry-tools/validate-project/cases/project-summary.js +5 -3
  42. package/dist/entry-tools/validate-project-service.d.ts +42 -36
  43. package/dist/entry-tools/validate-project-service.js +3 -1
  44. package/dist/entry-tools/verify-mixin-target-service.d.ts +30 -0
  45. package/dist/entry-tools/verify-mixin-target-service.js +113 -22
  46. package/dist/error-mapping.d.ts +51 -0
  47. package/dist/error-mapping.js +157 -2
  48. package/dist/index.js +138 -71
  49. package/dist/mapping/internal-types.d.ts +7 -0
  50. package/dist/mapping/lookup.d.ts +1 -1
  51. package/dist/mapping/lookup.js +7 -1
  52. package/dist/mapping/types.d.ts +6 -0
  53. package/dist/mapping-service.d.ts +26 -2
  54. package/dist/mapping-service.js +178 -36
  55. package/dist/mcp-helpers.d.ts +1 -0
  56. package/dist/mcp-helpers.js +25 -3
  57. package/dist/minecraft-explorer-service.js +24 -11
  58. package/dist/mixin/access-validators.js +33 -1
  59. package/dist/mixin/annotation-validators.js +53 -7
  60. package/dist/mixin/parsed-validator.js +2 -2
  61. package/dist/mixin-parser.js +48 -4
  62. package/dist/mod-decompile-service.js +2 -1
  63. package/dist/mod-remap-service.d.ts +20 -1
  64. package/dist/mod-remap-service.js +95 -40
  65. package/dist/nbt/java-nbt-codec.js +6 -0
  66. package/dist/observability.d.ts +6 -0
  67. package/dist/observability.js +10 -0
  68. package/dist/page-cursor.d.ts +17 -0
  69. package/dist/page-cursor.js +0 -0
  70. package/dist/registry-service.js +25 -1
  71. package/dist/resources.js +45 -9
  72. package/dist/response-utils.d.ts +29 -4
  73. package/dist/response-utils.js +95 -5
  74. package/dist/source/access-validate.js +12 -2
  75. package/dist/source/artifact-resolver.js +1 -1
  76. package/dist/source/class-source/members-builder.d.ts +44 -1
  77. package/dist/source/class-source/members-builder.js +51 -4
  78. package/dist/source/class-source/snippet-builder.d.ts +13 -0
  79. package/dist/source/class-source/snippet-builder.js +48 -4
  80. package/dist/source/class-source-helpers.js +67 -11
  81. package/dist/source/class-source.js +97 -20
  82. package/dist/source/indexer.js +5 -2
  83. package/dist/source/search.d.ts +1 -1
  84. package/dist/source/search.js +94 -22
  85. package/dist/source/validate-mixin.d.ts +2 -1
  86. package/dist/source/validate-mixin.js +45 -4
  87. package/dist/source/workspace-target.js +15 -9
  88. package/dist/source-jar-reader.d.ts +54 -1
  89. package/dist/source-jar-reader.js +121 -10
  90. package/dist/source-service.d.ts +28 -7
  91. package/dist/storage/files-repo.d.ts +12 -0
  92. package/dist/storage/files-repo.js +24 -0
  93. package/dist/storage/symbols-repo.d.ts +6 -0
  94. package/dist/storage/symbols-repo.js +6 -0
  95. package/dist/symbols/symbol-extractor.js +35 -6
  96. package/dist/text-truncate.d.ts +7 -0
  97. package/dist/text-truncate.js +21 -0
  98. package/dist/tiny-remapper-resolver.js +10 -0
  99. package/dist/tool-contract-manifest.js +2 -2
  100. package/dist/tool-execution-gate.js +12 -1
  101. package/dist/tool-guidance.d.ts +4 -1
  102. package/dist/tool-guidance.js +37 -12
  103. package/dist/tool-schemas.d.ts +559 -494
  104. package/dist/tool-schemas.js +99 -63
  105. package/dist/types.d.ts +1 -0
  106. package/dist/version-diff-service.js +5 -0
  107. package/dist/version-service.d.ts +3 -0
  108. package/dist/version-service.js +18 -2
  109. package/dist/warning-details.d.ts +18 -0
  110. package/dist/warning-details.js +63 -0
  111. package/docs/README-ja.md +2 -2
  112. package/docs/examples.md +26 -2
  113. package/docs/tool-reference.md +22 -17
  114. package/package.json +1 -1
@@ -5,7 +5,8 @@ import { ERROR_CODES, createError, isAppError } from "../errors.js";
5
5
  import * as artifactResolver from "./artifact-resolver.js";
6
6
  import * as classSourceHelpers from "./class-source-helpers.js";
7
7
  import { buildClassSourceSnippet } from "./class-source/snippet-builder.js";
8
- import { remapAndCountMembers, sliceMembersWithLimit } from "./class-source/members-builder.js";
8
+ import { remapAndCountMembers, sliceMembersWithLimit, projectMembersForWire } from "./class-source/members-builder.js";
9
+ import { buildPageContextKey, encodeOffsetCursor, resolveCursorOffset } from "../page-cursor.js";
9
10
  import { dedupeQualityFlags, normalizeMapping, normalizeOptionalString, normalizePathStyle } from "./shared-utils.js";
10
11
  const MEMBERS_STATUS_LEGACY = process.env.MEMBERS_STATUS_LEGACY === "1";
11
12
  function normalizeStrictPositiveInt(value, field) {
@@ -257,6 +258,9 @@ export function buildDecompiledFallback(svc, artifactId, lookupClassName, member
257
258
  }
258
259
  };
259
260
  }
261
+ // The class-like symbol kinds findClass returns. MUST stay in sync with the JS-side
262
+ // isTypeSymbol checks below; pushed down to SQL so non-type rows are never fetched.
263
+ const TYPE_SYMBOL_KINDS = ["class", "interface", "enum", "record"];
260
264
  export function findClass(svc, input) {
261
265
  const className = input.className.trim();
262
266
  if (!className) {
@@ -278,12 +282,20 @@ export function findClass(svc, input) {
278
282
  const warnings = [];
279
283
  const isQualified = className.includes(".");
280
284
  if (isQualified) {
281
- const classPath = className.replace(/\./g, "/");
285
+ // The innermost simple name (handles both dot- and $-separated inner types).
286
+ const simpleName = className.split(/[.$]/).at(-1) ?? className;
287
+ const directPath = `${classNameToClassPath(className)}.java`;
288
+ // The extractor stores ONE (outer) qualifiedName per file, so a nested type's
289
+ // FQCN never equals any stored qualifiedName and its synthetic file path
290
+ // (pkg/Outer/Inner.java) does not exist. resolveClassFilePath maps the FQCN —
291
+ // inner classes included — to the real outer file, which we match on.
292
+ const resolvedFilePath = resolveClassFilePath(svc, artifactId, className);
282
293
  const result = svc.symbolsRepo.findScopedSymbols({
283
294
  artifactId,
284
- query: className.split(".").at(-1) ?? className,
295
+ query: simpleName,
285
296
  match: "exact",
286
- limit: 5000
297
+ symbolKinds: TYPE_SYMBOL_KINDS,
298
+ limit: limit * 5
287
299
  });
288
300
  const matches = result.items
289
301
  .filter((row) => {
@@ -292,14 +304,22 @@ export function findClass(svc, input) {
292
304
  if (!isTypeSymbol)
293
305
  return false;
294
306
  const rowQualified = row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
295
- return rowQualified === className || row.filePath === `${classPath}.java`;
307
+ return (rowQualified === className ||
308
+ row.filePath === directPath ||
309
+ (resolvedFilePath != null && row.filePath === resolvedFilePath));
310
+ })
311
+ .map((row) => {
312
+ const rowQualified = row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", ".");
313
+ // For an inner-class match the stored qualifiedName is the outer type; the
314
+ // caller asked for the full nested FQCN, so report that.
315
+ const isInnerMatch = rowQualified !== className && row.filePath !== directPath;
316
+ return {
317
+ qualifiedName: isInnerMatch ? className : rowQualified,
318
+ filePath: row.filePath,
319
+ line: row.line,
320
+ symbolKind: row.symbolKind
321
+ };
296
322
  })
297
- .map((row) => ({
298
- qualifiedName: row.qualifiedName ?? row.filePath.replace(/\.java$/, "").replaceAll("/", "."),
299
- filePath: row.filePath,
300
- line: row.line,
301
- symbolKind: row.symbolKind
302
- }))
303
323
  .slice(0, limit);
304
324
  const partialVanillaLookup = hasPartialNetMinecraftCoverage(artifact.qualityFlags) && looksLikeDeobfuscatedClassName(className);
305
325
  const filteredMatches = partialVanillaLookup && matches.every((match) => !match.qualifiedName.startsWith("net.minecraft.") && !match.qualifiedName.startsWith("com.mojang."))
@@ -317,6 +337,7 @@ export function findClass(svc, input) {
317
337
  artifactId,
318
338
  query: className,
319
339
  match: "exact",
340
+ symbolKinds: TYPE_SYMBOL_KINDS,
320
341
  limit: limit * 5
321
342
  });
322
343
  const matches = [];
@@ -587,6 +608,27 @@ export async function getClassSource(svc, input) {
587
608
  requestedMapping,
588
609
  mappingApplied: activeMappingApplied
589
610
  });
611
+ const nextStartLine = snippet.nextStartLine;
612
+ // Continuation guidance: when output was truncated and was not redirected to
613
+ // a file, hand the caller the next line to read plus a replayable call that
614
+ // re-reads from the already-resolved artifact (no re-resolution needed).
615
+ // nextStartLine is only set for snippet/full mode, so `mode` here is never
616
+ // "metadata". The caller's original endLine window is preserved so the
617
+ // continuation never reads past the requested range.
618
+ const continuation = nextStartLine != null && !resolvedOutputFile
619
+ ? buildSuggestedCall({
620
+ tool: "get-class-source",
621
+ params: {
622
+ className,
623
+ target: { kind: "artifact", artifactId: activeArtifactId },
624
+ mode,
625
+ startLine: nextStartLine,
626
+ ...(input.endLine != null ? { endLine: input.endLine } : {}),
627
+ ...(input.maxLines != null ? { maxLines: input.maxLines } : {}),
628
+ ...(input.maxChars != null ? { maxChars: input.maxChars } : {})
629
+ }
630
+ })
631
+ : undefined;
590
632
  return {
591
633
  className,
592
634
  mode,
@@ -598,6 +640,8 @@ export async function getClassSource(svc, input) {
598
640
  },
599
641
  truncated,
600
642
  ...(charsTruncated ? { charsTruncated } : {}),
643
+ ...(snippet.outOfRange ? { outOfRange: true } : {}),
644
+ ...(nextStartLine != null ? { nextStartLine } : {}),
601
645
  origin: activeOrigin,
602
646
  artifactId: activeArtifactId,
603
647
  requestedMapping,
@@ -611,6 +655,7 @@ export async function getClassSource(svc, input) {
611
655
  isDecompiled: activeOrigin === "decompiled",
612
656
  qualityFlags: activeQualityFlags
613
657
  }),
658
+ ...(continuation?.suggestedCall ? { suggestedCall: continuation.suggestedCall } : {}),
614
659
  ...(resolvedOutputFile ? { outputFile: resolvedOutputFile } : {}),
615
660
  warnings
616
661
  };
@@ -629,7 +674,7 @@ export async function getClassMembers(svc, input) {
629
674
  const includeInherited = input.includeInherited ?? false;
630
675
  const memberPattern = normalizeOptionalString(input.memberPattern);
631
676
  const parsedMaxMembers = normalizeStrictPositiveInt(input.maxMembers, "maxMembers");
632
- const maxMembers = parsedMaxMembers == null ? 500 : Math.min(parsedMaxMembers, 5000);
677
+ const maxMembers = parsedMaxMembers == null ? 150 : Math.min(parsedMaxMembers, 5000);
633
678
  const normalizedArtifactId = normalizeOptionalString(input.artifactId);
634
679
  if (normalizedArtifactId && input.target) {
635
680
  throw createError({
@@ -759,7 +804,23 @@ export async function getClassMembers(svc, input) {
759
804
  }
760
805
  catch (error) {
761
806
  if (isAppError(error) && error.code === ERROR_CODES.CLASS_NOT_FOUND) {
762
- throw error;
807
+ // Re-raise with the shared recovery shape (find-class/api-matrix
808
+ // suggestedCall, namespace + scope hints) instead of the sparse bytecode
809
+ // error, so members and source agree on CLASS_NOT_FOUND guidance.
810
+ throw buildClassSourceNotFoundError(svc, {
811
+ artifactId,
812
+ className,
813
+ lookupClassName,
814
+ mappingApplied,
815
+ requestedMapping,
816
+ qualityFlags,
817
+ attemptedBinaryFallback: true,
818
+ targetKind: input.target?.kind,
819
+ targetValue: input.target && "value" in input.target ? input.target.value : undefined,
820
+ scope: input.scope,
821
+ projectPath: input.projectPath,
822
+ version
823
+ });
763
824
  }
764
825
  binaryExtractionFailed = true;
765
826
  binaryExtractionFailureReason = error instanceof Error ? error.message : String(error);
@@ -787,11 +848,29 @@ export async function getClassMembers(svc, input) {
787
848
  warnings
788
849
  });
789
850
  const counts = remapped.counts;
790
- const sliced = sliceMembersWithLimit(remapped, counts.total, maxMembers, warnings);
851
+ // Offset cursor over the flat [constructors, fields, methods] member sequence.
852
+ // The context key ties a cursor to this exact query so a stale cursor restarts.
853
+ const memberCursorContext = buildPageContextKey([
854
+ artifactId,
855
+ lookupClassName,
856
+ requestedMapping,
857
+ mappingApplied,
858
+ access,
859
+ includeSynthetic,
860
+ includeInherited,
861
+ memberPattern
862
+ ]);
863
+ const { offset: memberOffset, cursorIgnored: memberCursorIgnored } = resolveCursorOffset(input.cursor, memberCursorContext);
864
+ const sliced = sliceMembersWithLimit(remapped, counts.total, maxMembers, warnings, memberOffset);
791
865
  const constructors = sliced.constructors;
792
866
  const fields = sliced.fields;
793
867
  const methods = sliced.methods;
868
+ // Slim the wire member shape: hoist a shared ownerFqn, drop accessFlags, omit
869
+ // isSynthetic:false, and drop FIELD jvmDescriptor unless includeDescriptors.
870
+ // Internal SignatureMember arrays above stay intact.
871
+ const projectedMembers = projectMembersForWire({ constructors, fields, methods }, includeInherited, input.includeDescriptors ?? false);
794
872
  const truncated = sliced.truncated;
873
+ const nextCursor = sliced.nextOffset != null ? encodeOffsetCursor(sliced.nextOffset, memberCursorContext) : undefined;
795
874
  const normalizedProvenance = provenance ??
796
875
  buildFallbackProvenance(svc, {
797
876
  artifactId,
@@ -843,7 +922,7 @@ export async function getClassMembers(svc, input) {
843
922
  suggestedCall = buildSuggestedCall({
844
923
  tool: "get-class-source",
845
924
  params: {
846
- target: { type: "artifact", artifactId },
925
+ target: { kind: "artifact", artifactId },
847
926
  className,
848
927
  mode: "snippet",
849
928
  mapping: requestedMapping
@@ -861,13 +940,11 @@ export async function getClassMembers(svc, input) {
861
940
  }
862
941
  return {
863
942
  className,
864
- members: {
865
- constructors,
866
- fields,
867
- methods
868
- },
943
+ members: projectedMembers,
869
944
  counts,
870
945
  truncated,
946
+ ...(nextCursor ? { nextCursor } : {}),
947
+ ...(memberCursorIgnored ? { cursorIgnored: true } : {}),
871
948
  context: signatureContext,
872
949
  origin,
873
950
  artifactId,
@@ -43,8 +43,8 @@ export async function indexArtifact(svc, input) {
43
43
  }
44
44
  const artifact = svc.getArtifact(artifactId);
45
45
  const force = input.force ?? false;
46
- const hasFiles = svc.filesRepo.listFiles(artifact.artifactId, { limit: 1 }).items.length > 0;
47
46
  const meta = svc.indexMetaRepo.get(artifact.artifactId);
47
+ const hasFiles = meta ? meta.filesCount > 0 : false;
48
48
  const expectedSignature = artifact.artifactSignature ?? fallbackArtifactSignature(artifact.artifactId);
49
49
  const reason = resolveIndexRebuildReason({
50
50
  force,
@@ -291,8 +291,11 @@ export function getArtifact(svc, artifactId) {
291
291
  }
292
292
  export async function ingestIfNeeded(svc, resolved) {
293
293
  const existing = svc.artifactsRepo.getArtifact(resolved.artifactId);
294
- const hasFiles = svc.filesRepo.listFiles(resolved.artifactId, { limit: 1 }).items.length > 0;
295
294
  const meta = svc.indexMetaRepo.get(resolved.artifactId);
295
+ // Derive hasFiles from meta instead of a separate listFiles probe: when meta is
296
+ // absent the reason is "missing_meta" regardless of hasFiles, and when present
297
+ // meta.filesCount is the authoritative count written alongside the file rows.
298
+ const hasFiles = meta ? meta.filesCount > 0 : false;
296
299
  const reason = resolveIndexRebuildReason({
297
300
  force: false,
298
301
  expectedSignature: resolved.artifactSignature,
@@ -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;
@@ -182,6 +182,11 @@ export function findContentMatchIndex(content, query, match, pattern) {
182
182
  }
183
183
  const normalizedContent = toLower(content);
184
184
  const normalizedQuery = toLower(query);
185
+ // Both "contains" and "prefix" map to a substring search here. For free-text
186
+ // content, `prefix` is intentionally treated as "contains": file bodies are
187
+ // searched grep-style and the default match for intent=text resolves to
188
+ // "prefix" (see normalizeMatch), so anchoring at the start of the file would
189
+ // yield almost no hits. Symbol/path prefix anchoring lives in findMatchIndex.
185
190
  return normalizedContent.indexOf(normalizedQuery);
186
191
  }
187
192
  export function scoreSymbolMatch(match, index, symbolKind) {
@@ -336,6 +341,10 @@ export async function searchClassSource(svc, input) {
336
341
  });
337
342
  const decodedCursor = decodeSearchCursor(input.cursor);
338
343
  const cursor = decodedCursor?.contextKey === cursorContext ? decodedCursor : undefined;
344
+ // A provided cursor that did not decode, or whose context key does not match
345
+ // this query, is silently dropped and the scan restarts from page one. Flag
346
+ // it so callers do not assume they are continuing a previous page.
347
+ const cursorIgnored = input.cursor != null && cursor == null;
339
348
  const accumulator = createSearchHitAccumulator(limit, cursor);
340
349
  const indexedSearchEnabled = svc.config.indexedSearchEnabled !== false;
341
350
  if (match === "regex") {
@@ -345,13 +354,16 @@ export async function searchClassSource(svc, input) {
345
354
  const recordHit = (hit) => {
346
355
  accumulator.add(hit);
347
356
  };
357
+ const recordWarning = (warning) => {
358
+ searchWarnings.push(warning);
359
+ };
348
360
  const tokenOnlyTextIntent = intent === "text" && queryMode === "token";
349
361
  if (intent === "symbol") {
350
362
  searchSymbolIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
351
363
  }
352
364
  else if (queryMode === "literal" && intent === "text") {
353
365
  svc.metrics.recordSearchFallback();
354
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
366
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
355
367
  }
356
368
  else if (!indexedSearchEnabled) {
357
369
  svc.metrics.recordIndexedDisabled();
@@ -361,7 +373,7 @@ export async function searchClassSource(svc, input) {
361
373
  searchPathIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
362
374
  }
363
375
  else {
364
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
376
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
365
377
  }
366
378
  }
367
379
  }
@@ -388,7 +400,7 @@ export async function searchClassSource(svc, input) {
388
400
  searchPathIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
389
401
  }
390
402
  else {
391
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
403
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
392
404
  }
393
405
  }
394
406
  }
@@ -400,7 +412,7 @@ export async function searchClassSource(svc, input) {
400
412
  searchPathIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
401
413
  }
402
414
  else {
403
- searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit);
415
+ searchTextIntent(svc, artifact.artifactId, query, match, scope, regexPattern, recordHit, recordWarning);
404
416
  }
405
417
  }
406
418
  }
@@ -415,6 +427,7 @@ export async function searchClassSource(svc, input) {
415
427
  return {
416
428
  hits: page,
417
429
  nextCursor,
430
+ ...(cursorIgnored ? { cursorIgnored: true } : {}),
418
431
  mappingApplied: artifact.mappingApplied ?? "obfuscated",
419
432
  returnedNamespace: artifact.mappingApplied ?? "obfuscated",
420
433
  artifactContents: svc.buildArtifactContentsSummary({
@@ -534,39 +547,98 @@ export function searchPathIntentIndexed(svc, artifactId, query, match, scope, on
534
547
  });
535
548
  }
536
549
  }
537
- export function searchTextIntent(svc, artifactId, query, match, scope, regexPattern, onHit) {
538
- const pageSize = Math.max(1, svc.config.searchScanPageSize ?? 250);
550
+ const ASCII_RE = /^[\x00-\x7F]*$/;
551
+ /** True when every code unit is ASCII, so SQLite LIKE folds case identically to JS. */
552
+ function isAsciiNeedle(query) {
553
+ return ASCII_RE.test(query);
554
+ }
555
+ function searchScanBudgetWarning(byteBudget) {
556
+ return `search scan budget of ${byteBudget} bytes reached; results may be incomplete. Narrow the query or scope (packagePrefix / fileGlob) to scan fewer files.`;
557
+ }
558
+ export function searchTextIntent(svc, artifactId, query, match, scope, regexPattern, onHit, onWarning) {
539
559
  const glob = scope?.fileGlob ? buildGlobRegex(normalizePathStyle(scope.fileGlob)) : undefined;
560
+ const byteBudget = Math.max(1, svc.config.searchScanMaxBytes ?? Number.MAX_SAFE_INTEGER);
561
+ let scannedBytes = 0;
562
+ let truncated = false;
563
+ const passesScope = (filePath) => checkPackagePrefix(filePath, scope?.packagePrefix) && (!glob || glob.test(filePath));
564
+ const emitIfMatch = (filePath, content) => {
565
+ const contentIndex = match === "regex"
566
+ ? matchRegexIndex(content, regexPattern)
567
+ : findContentMatchIndex(content, query, match);
568
+ if (contentIndex < 0) {
569
+ return;
570
+ }
571
+ onHit({
572
+ filePath,
573
+ score: scoreTextMatch(match, contentIndex),
574
+ matchedIn: "content",
575
+ reasonCodes: ["content_match", `text_${match}`]
576
+ });
577
+ };
578
+ // FAST PATH: an ASCII, non-regex needle narrows candidates via a content LIKE
579
+ // prefilter (a proven superset of the JS match), so only matching files are
580
+ // hydrated instead of scanning the whole corpus. The unchanged JS post-verify in
581
+ // emitIfMatch removes any over-included rows.
582
+ if (match !== "regex" && isAsciiNeedle(query)) {
583
+ const candidateLimit = indexedCandidateLimitForMatch(svc, match);
584
+ const { filePaths, scannedRows } = svc.filesRepo.searchContentLikeCandidatePaths(artifactId, query, candidateLimit);
585
+ svc.metrics.recordSearchDbRoundtrip();
586
+ svc.metrics.recordSearchRowsScanned(scannedRows);
587
+ // The prefilter caps candidates at candidateLimit ordered by file_path. Below the
588
+ // cap the candidate set is exhaustive, so the JS post-verify yields exactly the old
589
+ // full-scan hits. AT the cap more matches may exist beyond the path-ordered cut, and
590
+ // dropping them would change the scored top-K page and totalApprox/cursor — so fall
591
+ // through to the exhaustive paged scan below to preserve identical results.
592
+ if (scannedRows < candidateLimit) {
593
+ svc.metrics.recordSearchLikePrefilter();
594
+ const scopedPaths = filePaths.filter(passesScope);
595
+ const rows = svc.filesRepo.getFileContentsByPaths(artifactId, scopedPaths);
596
+ svc.metrics.recordSearchDbRoundtrip();
597
+ svc.metrics.recordSearchRowsScanned(rows.length);
598
+ for (const row of rows) {
599
+ if (scannedBytes >= byteBudget) {
600
+ truncated = true;
601
+ break;
602
+ }
603
+ scannedBytes += row.contentBytes;
604
+ emitIfMatch(row.filePath, row.content);
605
+ }
606
+ if (truncated) {
607
+ svc.metrics.recordSearchScanTruncated();
608
+ onWarning?.(searchScanBudgetWarning(byteBudget));
609
+ }
610
+ return;
611
+ }
612
+ // Cap overflow: fall through to the exhaustive scan (rare; only common-token queries).
613
+ }
614
+ // SLOW PATH: regex (cannot be pushed to SQL) or non-ASCII needles (SQLite LIKE
615
+ // would fold case differently than JS) scan the corpus page by page.
616
+ const pageSize = Math.max(1, svc.config.searchScanPageSize ?? 250);
540
617
  let cursor = undefined;
541
- while (true) {
618
+ outer: while (true) {
542
619
  const page = svc.filesRepo.listFileRows(artifactId, { limit: pageSize, cursor });
543
620
  svc.metrics.recordSearchDbRoundtrip();
544
621
  svc.metrics.recordSearchRowsScanned(page.items.length);
545
622
  for (const row of page.items) {
546
- if (!checkPackagePrefix(row.filePath, scope?.packagePrefix)) {
623
+ if (!passesScope(row.filePath)) {
547
624
  continue;
548
625
  }
549
- if (glob && !glob.test(row.filePath)) {
550
- continue;
626
+ if (scannedBytes >= byteBudget) {
627
+ truncated = true;
628
+ break outer;
551
629
  }
552
- const contentIndex = match === "regex"
553
- ? matchRegexIndex(row.content, regexPattern)
554
- : findContentMatchIndex(row.content, query, match);
555
- if (contentIndex < 0) {
556
- continue;
557
- }
558
- onHit({
559
- filePath: row.filePath,
560
- score: scoreTextMatch(match, contentIndex),
561
- matchedIn: "content",
562
- reasonCodes: ["content_match", `text_${match}`]
563
- });
630
+ scannedBytes += row.contentBytes;
631
+ emitIfMatch(row.filePath, row.content);
564
632
  }
565
633
  if (!page.nextCursor) {
566
634
  break;
567
635
  }
568
636
  cursor = page.nextCursor;
569
637
  }
638
+ if (truncated) {
639
+ svc.metrics.recordSearchScanTruncated();
640
+ onWarning?.(searchScanBudgetWarning(byteBudget));
641
+ }
570
642
  }
571
643
  export function searchPathIntent(svc, artifactId, query, match, scope, regexPattern, onHit) {
572
644
  const pageSize = Math.max(1, svc.config.searchScanPageSize ?? 250);
@@ -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
  }
@@ -256,15 +256,21 @@ export async function synthesizeDependencyTarget(svc, input, dep) {
256
256
  nextAction,
257
257
  ...buildSuggestedCall({
258
258
  tool: "resolve-artifact",
259
- params: {
260
- target: {
261
- kind: "dependency",
262
- group,
263
- name,
264
- version: "<your-version>"
265
- },
266
- projectPath
267
- }
259
+ params: undefined,
260
+ examples: [
261
+ {
262
+ params: {
263
+ target: {
264
+ kind: "dependency",
265
+ group,
266
+ name,
267
+ version: "<your-version>"
268
+ },
269
+ projectPath
270
+ },
271
+ reason: "Replace <your-version> with the dependency version, or declare it in gradle.properties."
272
+ }
273
+ ]
268
274
  })
269
275
  }
270
276
  });
@@ -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 {};