@adhisang/minecraft-modding-mcp 4.2.0 → 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 (120) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +6 -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 +99 -6
  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/errors.js +4 -1
  49. package/dist/index.js +138 -71
  50. package/dist/java-process.d.ts +9 -0
  51. package/dist/java-process.js +11 -5
  52. package/dist/mapping/internal-types.d.ts +7 -0
  53. package/dist/mapping/lookup.d.ts +1 -1
  54. package/dist/mapping/lookup.js +7 -1
  55. package/dist/mapping/types.d.ts +6 -0
  56. package/dist/mapping-service.d.ts +26 -2
  57. package/dist/mapping-service.js +178 -36
  58. package/dist/mcp-helpers.d.ts +1 -0
  59. package/dist/mcp-helpers.js +25 -3
  60. package/dist/minecraft-explorer-service.js +24 -11
  61. package/dist/mixin/access-validators.js +33 -1
  62. package/dist/mixin/annotation-validators.js +53 -7
  63. package/dist/mixin/parsed-validator.js +2 -2
  64. package/dist/mixin-parser.js +48 -4
  65. package/dist/mod-decompile-service.js +2 -1
  66. package/dist/mod-remap-service.d.ts +20 -1
  67. package/dist/mod-remap-service.js +95 -40
  68. package/dist/nbt/java-nbt-codec.js +87 -2
  69. package/dist/observability.d.ts +6 -0
  70. package/dist/observability.js +10 -0
  71. package/dist/page-cursor.d.ts +17 -0
  72. package/dist/page-cursor.js +0 -0
  73. package/dist/registry-service.js +25 -1
  74. package/dist/resources.js +45 -9
  75. package/dist/response-utils.d.ts +29 -4
  76. package/dist/response-utils.js +95 -5
  77. package/dist/source/access-validate.js +12 -2
  78. package/dist/source/artifact-resolver.js +1 -1
  79. package/dist/source/cache-metrics.js +2 -2
  80. package/dist/source/class-source/members-builder.d.ts +44 -1
  81. package/dist/source/class-source/members-builder.js +51 -4
  82. package/dist/source/class-source/snippet-builder.d.ts +13 -0
  83. package/dist/source/class-source/snippet-builder.js +48 -4
  84. package/dist/source/class-source-helpers.js +67 -11
  85. package/dist/source/class-source.js +97 -20
  86. package/dist/source/indexer.d.ts +31 -6
  87. package/dist/source/indexer.js +95 -25
  88. package/dist/source/search.d.ts +1 -1
  89. package/dist/source/search.js +94 -22
  90. package/dist/source/validate-mixin.d.ts +2 -1
  91. package/dist/source/validate-mixin.js +45 -4
  92. package/dist/source/workspace-target.js +15 -9
  93. package/dist/source-jar-reader.d.ts +54 -1
  94. package/dist/source-jar-reader.js +121 -10
  95. package/dist/source-service.d.ts +28 -7
  96. package/dist/storage/files-repo.d.ts +12 -0
  97. package/dist/storage/files-repo.js +24 -0
  98. package/dist/storage/symbols-repo.d.ts +6 -0
  99. package/dist/storage/symbols-repo.js +6 -0
  100. package/dist/symbols/symbol-extractor.js +35 -6
  101. package/dist/text-truncate.d.ts +7 -0
  102. package/dist/text-truncate.js +21 -0
  103. package/dist/tiny-remapper-resolver.js +10 -0
  104. package/dist/tiny-remapper-service.js +3 -3
  105. package/dist/tool-contract-manifest.js +2 -2
  106. package/dist/tool-execution-gate.js +12 -1
  107. package/dist/tool-guidance.d.ts +4 -1
  108. package/dist/tool-guidance.js +37 -12
  109. package/dist/tool-schemas.d.ts +559 -494
  110. package/dist/tool-schemas.js +99 -63
  111. package/dist/types.d.ts +1 -0
  112. package/dist/version-diff-service.js +5 -0
  113. package/dist/version-service.d.ts +3 -0
  114. package/dist/version-service.js +18 -2
  115. package/dist/warning-details.d.ts +18 -0
  116. package/dist/warning-details.js +63 -0
  117. package/docs/README-ja.md +2 -2
  118. package/docs/examples.md +26 -2
  119. package/docs/tool-reference.md +23 -17
  120. package/package.json +1 -1
@@ -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 {};
@@ -3,6 +3,15 @@ import { createError, ERROR_CODES } from "./errors.js";
3
3
  import { log } from "./logger.js";
4
4
  import { isSecureJarEntryPath } from "./path-resolver.js";
5
5
  const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
6
+ // Test-only instrumentation: counts real jar opens at the single openZipFile
7
+ // chokepoint so perf tests can assert N-opens-per-call collapses to 1.
8
+ let zipOpenCount = 0;
9
+ export function __getZipOpenCount() {
10
+ return zipOpenCount;
11
+ }
12
+ export function __resetZipOpenCount() {
13
+ zipOpenCount = 0;
14
+ }
6
15
  function toErrorMessage(value) {
7
16
  if (value instanceof Error) {
8
17
  return value.message;
@@ -23,7 +32,7 @@ export function hasJavaSourceExtension(entryPath) {
23
32
  }
24
33
  return true;
25
34
  }
26
- function openZipFile(jarPath) {
35
+ export function openZipFile(jarPath) {
27
36
  return new Promise((resolve, reject) => {
28
37
  yauzl.open(jarPath, {
29
38
  lazyEntries: true,
@@ -33,6 +42,7 @@ function openZipFile(jarPath) {
33
42
  reject(new Error(`Failed to read jar "${jarPath}": ${toErrorMessage(error)}`));
34
43
  return;
35
44
  }
45
+ zipOpenCount += 1;
36
46
  resolve(zipFile);
37
47
  });
38
48
  });
@@ -193,6 +203,66 @@ export async function readJarEntryAsBuffer(jarPath, entryPath) {
193
203
  }
194
204
  });
195
205
  }
206
+ /**
207
+ * Opens a jar ONCE, drains its central directory into a name->entry index, and
208
+ * serves repeated entry reads via O(1) lookup + openReadStream. Use this when a
209
+ * single logical operation reads many entries from the same jar (e.g. walking a
210
+ * class hierarchy) instead of calling {@link readJarEntryAsBuffer} per entry,
211
+ * which re-opens and re-scans the jar each time. Callers MUST close() the reader
212
+ * (in a finally) to release the file descriptor. getEntryBuffer throws the same
213
+ * INVALID_INPUT (unsafe path) / SOURCE_NOT_FOUND (missing entry) errors as
214
+ * readJarEntryAsBuffer; duplicate entry names resolve to the first occurrence.
215
+ */
216
+ export async function createJarEntryReader(jarPath) {
217
+ const zipFile = await openZipFile(jarPath);
218
+ let closed = false;
219
+ const index = new Map();
220
+ try {
221
+ while (true) {
222
+ const entry = await readNextEntry(zipFile);
223
+ if (!entry) {
224
+ break;
225
+ }
226
+ if (!isSecureJarEntryPath(entry.fileName)) {
227
+ continue;
228
+ }
229
+ if (!index.has(entry.fileName)) {
230
+ index.set(entry.fileName, entry);
231
+ }
232
+ }
233
+ }
234
+ catch (error) {
235
+ zipFile.close();
236
+ throw error;
237
+ }
238
+ return {
239
+ async getEntryBuffer(entryPath) {
240
+ const normalizedTargetPath = entryPath.replaceAll("\\", "/");
241
+ if (!isSecureJarEntryPath(normalizedTargetPath)) {
242
+ throw createError({
243
+ code: ERROR_CODES.INVALID_INPUT,
244
+ message: `Entry path "${normalizedTargetPath}" is not allowed.`,
245
+ details: { jarPath, entryPath: normalizedTargetPath }
246
+ });
247
+ }
248
+ const entry = index.get(normalizedTargetPath);
249
+ if (!entry) {
250
+ throw createError({
251
+ code: ERROR_CODES.SOURCE_NOT_FOUND,
252
+ message: `Entry "${normalizedTargetPath}" was not found in "${jarPath}".`,
253
+ details: { jarPath, entryPath: normalizedTargetPath }
254
+ });
255
+ }
256
+ return readEntryStream(zipFile, entry, jarPath);
257
+ },
258
+ close() {
259
+ if (!closed) {
260
+ closed = true;
261
+ zipFile.close();
262
+ }
263
+ }
264
+ };
265
+ }
196
266
  export async function collectMatchedJarEntriesAsUtf8(jarPath, predicate, options = {}) {
197
267
  return withZipFile(jarPath, async (zipFile) => {
198
268
  const entries = [];
@@ -226,6 +296,48 @@ export async function collectMatchedJarEntriesAsUtf8(jarPath, predicate, options
226
296
  }
227
297
  });
228
298
  }
299
+ /**
300
+ * Raw-buffer counterpart of {@link collectMatchedJarEntriesAsUtf8}: opens the jar
301
+ * ONCE and returns the matched entries as raw Buffers (no UTF-8 decode), so binary
302
+ * inputs like `.class` bytes survive intact. Used to avoid re-opening the jar per
303
+ * sampled entry. Mirrors the sibling's maxBytes/maxEntries/continueOnError semantics
304
+ * and its finally-close, but takes an injectable open seam for open-count assertions.
305
+ */
306
+ export async function collectMatchedJarEntriesAsBuffers(jarPath, predicate, options = {}, deps = {}) {
307
+ const open = deps.openZipFile ?? openZipFile;
308
+ const zipFile = await open(jarPath);
309
+ try {
310
+ const entries = [];
311
+ const maxEntries = options.maxEntries == null ? undefined : Math.max(1, Math.trunc(options.maxEntries));
312
+ while (true) {
313
+ const entry = await readNextEntry(zipFile);
314
+ if (!entry) {
315
+ return entries;
316
+ }
317
+ if (!isSecureJarEntryPath(entry.fileName)) {
318
+ continue;
319
+ }
320
+ if (!predicate(entry.fileName)) {
321
+ continue;
322
+ }
323
+ try {
324
+ const contentBuffer = await readEntryStream(zipFile, entry, jarPath, options.maxBytes);
325
+ entries.push({ filePath: entry.fileName, content: contentBuffer });
326
+ if (maxEntries != null && entries.length >= maxEntries) {
327
+ return entries;
328
+ }
329
+ }
330
+ catch (error) {
331
+ if (!options.continueOnError) {
332
+ throw error;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ finally {
338
+ zipFile.close();
339
+ }
340
+ }
229
341
  export async function* iterateJavaEntriesAsUtf8(jarPath, maxBytes) {
230
342
  const zipFile = await openZipFile(jarPath);
231
343
  try {
@@ -285,7 +397,7 @@ function countMatches(input, pattern) {
285
397
  }
286
398
  return count;
287
399
  }
288
- export async function detectFabricLikeInputNamespace(inputJar) {
400
+ export async function detectFabricLikeInputNamespace(inputJar, deps = {}) {
289
401
  const warnings = [];
290
402
  const classEntries = (await listJarEntries(inputJar))
291
403
  .filter((entry) => entry.endsWith(".class"))
@@ -297,16 +409,15 @@ export async function detectFabricLikeInputNamespace(inputJar) {
297
409
  warnings
298
410
  };
299
411
  }
412
+ // Read all sampled class entries in a SINGLE jar open instead of re-opening the
413
+ // jar once per entry. The sample set and latin1 decode are unchanged, so scores —
414
+ // and therefore the detected namespace — are identical.
415
+ const sampleSet = new Set(classEntries);
416
+ const matched = await collectMatchedJarEntriesAsBuffers(inputJar, (name) => sampleSet.has(name), { continueOnError: true }, deps);
300
417
  let mojangScore = 0;
301
418
  let intermediaryScore = 0;
302
- for (const entry of classEntries) {
303
- let text = "";
304
- try {
305
- text = (await readJarEntryAsBuffer(inputJar, entry)).toString("latin1");
306
- }
307
- catch {
308
- continue;
309
- }
419
+ for (const { content } of matched) {
420
+ const text = content.toString("latin1");
310
421
  mojangScore += countMatches(text, /net\/minecraft\/(?:advancements|client|commands|core|data|gametest|nbt|network|recipe|resources|server|sounds|stats|tags|util|world)\//g) * 3;
311
422
  intermediaryScore += countMatches(text, /net\/minecraft\/class_\d+/g) * 3;
312
423
  intermediaryScore += countMatches(text, /\b(?:method|field)_\d+\b/g);
@@ -11,6 +11,7 @@ import * as indexer from "./source/indexer.js";
11
11
  import * as workspaceTarget from "./source/workspace-target.js";
12
12
  import * as validateMixinModule from "./source/validate-mixin.js";
13
13
  import * as artifactResolver from "./source/artifact-resolver.js";
14
+ import type { WireMembersBlock } from "./source/class-source/members-builder.js";
14
15
  import { type StageEmitter } from "./stage-emitter.js";
15
16
  import { WorkspaceMappingService, type WorkspaceCompileMappingOutput } from "./workspace-mapping-service.js";
16
17
  import { type WorkspaceContextCache } from "./workspace-context-cache.js";
@@ -30,7 +31,8 @@ export type ResolveArtifactInput = {
30
31
  scope?: ArtifactScope;
31
32
  preferProjectVersion?: boolean;
32
33
  strictVersion?: boolean;
33
- compact?: boolean;
34
+ /** When true, compute the optional `sampleEntries` listing (detail=full / include=samples). */
35
+ includeSampleEntries?: boolean;
34
36
  };
35
37
  export type ResolveArtifactOutput = {
36
38
  artifactId: string;
@@ -112,6 +114,8 @@ export type SearchClassSourceInput = {
112
114
  export type SearchClassSourceOutput = {
113
115
  hits: SearchSourceHit[];
114
116
  nextCursor?: string;
117
+ /** True when a provided cursor was malformed or belonged to a different query and was ignored (results restart from page one). */
118
+ cursorIgnored?: boolean;
115
119
  mappingApplied: SourceMapping;
116
120
  returnedNamespace: SourceMapping;
117
121
  artifactContents: ArtifactContentsSummary;
@@ -205,6 +209,10 @@ export type GetClassSourceOutput = {
205
209
  };
206
210
  truncated: boolean;
207
211
  charsTruncated?: boolean;
212
+ /** True when startLine begins past EOF; the returned range is empty (not clamped). */
213
+ outOfRange?: boolean;
214
+ /** First not-yet-returned line when truncated; pass back as startLine to continue. */
215
+ nextStartLine?: number;
208
216
  origin: ResolvedSourceArtifact["origin"];
209
217
  artifactId: string;
210
218
  requestedMapping: SourceMapping;
@@ -213,6 +221,11 @@ export type GetClassSourceOutput = {
213
221
  provenance: ArtifactProvenance;
214
222
  qualityFlags: string[];
215
223
  artifactContents: ArtifactContentsSummary;
224
+ /** Replayable get-class-source call that reads from nextStartLine (truncated, non-file output only). */
225
+ suggestedCall?: {
226
+ tool: string;
227
+ params: Record<string, unknown>;
228
+ };
216
229
  outputFile?: string;
217
230
  warnings: string[];
218
231
  };
@@ -245,11 +258,14 @@ export type GetClassMembersInput = {
245
258
  includeInherited?: boolean;
246
259
  memberPattern?: string;
247
260
  maxMembers?: number;
261
+ cursor?: string;
248
262
  projectPath?: string;
249
263
  gradleUserHome?: string;
250
264
  scope?: ArtifactScope;
251
265
  preferProjectVersion?: boolean;
252
266
  strictVersion?: boolean;
267
+ /** When true, keep jvmDescriptor on FIELD members (always kept on methods/constructors). */
268
+ includeDescriptors?: boolean;
253
269
  };
254
270
  export type DecompiledMember = {
255
271
  name: string;
@@ -265,11 +281,7 @@ export type DecompiledFallback = {
265
281
  export type GetClassMembersStatus = "ok" | "members_unavailable" | "partial";
266
282
  export type GetClassMembersOutput = {
267
283
  className: string;
268
- members: {
269
- constructors: SignatureMember[];
270
- fields: SignatureMember[];
271
- methods: SignatureMember[];
272
- };
284
+ members: WireMembersBlock;
273
285
  counts: {
274
286
  constructors: number;
275
287
  fields: number;
@@ -277,6 +289,10 @@ export type GetClassMembersOutput = {
277
289
  total: number;
278
290
  };
279
291
  truncated: boolean;
292
+ /** Continuation cursor when more members remain; pass back as cursor to fetch the next page. */
293
+ nextCursor?: string;
294
+ /** True when a provided cursor was malformed or belonged to a different query and was ignored. */
295
+ cursorIgnored?: boolean;
280
296
  context: ExplorerResponseContext;
281
297
  origin: ResolvedSourceArtifact["origin"];
282
298
  artifactId: string;
@@ -420,7 +436,12 @@ export type ValidateMixinInput = {
420
436
  path: string;
421
437
  };
422
438
  sourceRoots?: string[];
423
- version: string;
439
+ /**
440
+ * Optional when input.mode='project', or preferProjectVersion=true with a
441
+ * projectPath set: the dispatcher detects the Minecraft version from
442
+ * gradle.properties before any version-dependent stage runs.
443
+ */
444
+ version?: string;
424
445
  mapping?: SourceMapping;
425
446
  sourcePriority?: MappingSourcePriority;
426
447
  scope?: ArtifactScope;