@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
@@ -67,17 +67,73 @@ export function extractDecompiledMembers(className, filePath, content) {
67
67
  }
68
68
  return { constructors, fields, methods };
69
69
  }
70
+ /**
71
+ * Remove everything that must not count toward brace depth — line comments,
72
+ * (possibly multi-line) block comments, string literals, and char literals —
73
+ * in a single stateful pass so that block comments spanning lines and char
74
+ * literals like '{' / '}' never skew brace accounting. Line indices are
75
+ * preserved (one output entry per input line). Text blocks (""" … """) are not
76
+ * special-cased; they are vanishingly rare in decompiled output.
77
+ */
78
+ function stripBraceNoise(lines) {
79
+ const out = new Array(lines.length);
80
+ let inBlockComment = false;
81
+ for (let i = 0; i < lines.length; i += 1) {
82
+ const line = lines[i] ?? "";
83
+ let result = "";
84
+ let j = 0;
85
+ while (j < line.length) {
86
+ const ch = line[j];
87
+ const next = line[j + 1];
88
+ if (inBlockComment) {
89
+ if (ch === "*" && next === "/") {
90
+ inBlockComment = false;
91
+ j += 2;
92
+ }
93
+ else {
94
+ j += 1;
95
+ }
96
+ continue;
97
+ }
98
+ if (ch === "/" && next === "/") {
99
+ break; // line comment: ignore the rest of the line
100
+ }
101
+ if (ch === "/" && next === "*") {
102
+ inBlockComment = true;
103
+ j += 2;
104
+ continue;
105
+ }
106
+ if (ch === '"' || ch === "'") {
107
+ const quote = ch;
108
+ j += 1;
109
+ while (j < line.length) {
110
+ if (line[j] === "\\") {
111
+ j += 2;
112
+ continue;
113
+ }
114
+ if (line[j] === quote) {
115
+ j += 1;
116
+ break;
117
+ }
118
+ j += 1;
119
+ }
120
+ continue;
121
+ }
122
+ result += ch;
123
+ j += 1;
124
+ }
125
+ out[i] = result;
126
+ }
127
+ return out;
128
+ }
70
129
  export function computeLineBraceDepths(lines) {
130
+ const stripped = stripBraceNoise(lines);
71
131
  const depths = new Array(lines.length).fill(0);
72
132
  let depth = 0;
73
- for (let i = 0; i < lines.length; i += 1) {
133
+ for (let i = 0; i < stripped.length; i += 1) {
74
134
  // Entry depth for this line = depth observed before any brace on it.
75
135
  depths[i] = depth;
76
- const stripped = (lines[i] ?? "")
77
- .replace(/\/\/.*/g, "")
78
- .replace(/"(?:\\.|[^"\\])*"/g, "\"\"")
79
- .replace(/'(?:\\.|[^'\\])*'/g, "''");
80
- for (const char of stripped) {
136
+ for (const char of stripped[i] ?? "") {
81
137
  if (char === "{") {
82
138
  depth += 1;
83
139
  }
@@ -98,13 +154,13 @@ export function computeBraceRange(lines, symbols, simpleName) {
98
154
  return scanBraceRange(lines, classSymbol.line);
99
155
  }
100
156
  export function scanBraceRange(lines, declarationLine) {
157
+ // Strip from the start of the file so multi-line block-comment state is correct
158
+ // by the time we reach declarationLine; indices stay aligned with `lines`.
159
+ const stripped = stripBraceNoise(lines);
101
160
  let depth = 0;
102
161
  let started = false;
103
- for (let i = declarationLine - 1; i < lines.length; i += 1) {
104
- const stripped = (lines[i] ?? "")
105
- .replace(/\/\/.*/g, "")
106
- .replace(/"(?:\\.|[^"\\])*"/g, "\"\"");
107
- for (const char of stripped) {
162
+ for (let i = declarationLine - 1; i < stripped.length; i += 1) {
163
+ for (const char of stripped[i] ?? "") {
108
164
  if (char === "{") {
109
165
  depth += 1;
110
166
  started = true;
@@ -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,
@@ -1,7 +1,22 @@
1
+ import { resolveMojangTinyFile } from "../mojang-tiny-mapping-service.js";
1
2
  import type { SourceService } from "../source-service.js";
2
3
  import type { ArtifactIndexMetaRow } from "../storage/index-meta-repo.js";
3
4
  import type { ArtifactRow, ResolvedSourceArtifact } from "../types.js";
5
+ import { remapJar } from "../tiny-remapper-service.js";
6
+ import { resolveTinyRemapperJar } from "../tiny-remapper-resolver.js";
4
7
  export declare const INDEX_SCHEMA_VERSION = 1;
8
+ /**
9
+ * Injectable dependencies let remap-cache regression tests run without Java.
10
+ * Production callers use `defaultBinaryRemapDeps`; this is not a public MCP
11
+ * extension point.
12
+ */
13
+ export type BinaryRemapDeps = {
14
+ resolveTinyRemapperJar: typeof resolveTinyRemapperJar;
15
+ resolveMojangTinyFile: typeof resolveMojangTinyFile;
16
+ remapJar: typeof remapJar;
17
+ now: () => number;
18
+ randomSuffix: () => string;
19
+ };
5
20
  export type IndexRebuildReason = "force" | "missing_meta" | "schema_mismatch" | "signature_mismatch" | "already_current";
6
21
  export interface IndexedFileRecord {
7
22
  filePath: string;
@@ -57,13 +72,12 @@ export declare function ingestIfNeeded(svc: SourceService, resolved: ResolvedSou
57
72
  * binary remap, run tiny-remapper now and return the remapped jar path.
58
73
  * Otherwise return the original binaryJarPath unchanged.
59
74
  *
60
- * Cache safety: writes to a per-attempt temp file then atomic-renames into
61
- * <cacheDir>/remapped/<artifactId>.jar. A per-target inflight Promise map
62
- * collapses concurrent calls so two simultaneous resolveArtifact calls for
63
- * the same artifactId share one tiny-remapper run instead of racing on the
64
- * same output path.
75
+ * Cache safety: writes to a per-attempt `.jar` temp path, validates ZIP magic,
76
+ * then atomic-renames into <cacheDir>/remapped/<artifactId>.jar. A per-target
77
+ * inflight Promise map collapses concurrent calls so simultaneous
78
+ * resolveArtifact calls for the same artifactId share one tiny-remapper run.
65
79
  */
66
- export declare function maybeRemapBinaryForMojang(svc: SourceService, resolved: ResolvedSourceArtifact): Promise<string>;
80
+ export declare function maybeRemapBinaryForMojang(svc: SourceService, resolved: ResolvedSourceArtifact, deps?: BinaryRemapDeps): Promise<string>;
67
81
  export declare function recordRemappedJarBytesFromDisk(svc: SourceService, artifactId: string, path: string): Promise<void>;
68
82
  /**
69
83
  * Best-effort structural check that `path` is a non-empty file beginning with
@@ -73,10 +87,21 @@ export declare function recordRemappedJarBytesFromDisk(svc: SourceService, artif
73
87
  * are not (a corrupt cache hit must be evicted).
74
88
  */
75
89
  export declare function isUsableJarFile(path: string): Promise<boolean>;
90
+ export declare function buildBinaryRemapTempPath(remappedJarPath: string, input: {
91
+ pid: number;
92
+ now: number;
93
+ randomSuffix: string;
94
+ }): string;
76
95
  export declare function runBinaryRemap(svc: SourceService, input: {
77
96
  version: string;
78
97
  inputJar: string;
79
98
  remappedDir: string;
80
99
  remappedJarPath: string;
81
100
  }): Promise<string>;
101
+ export declare function runBinaryRemapWithDeps(svc: SourceService, input: {
102
+ version: string;
103
+ inputJar: string;
104
+ remappedDir: string;
105
+ remappedJarPath: string;
106
+ }, deps: BinaryRemapDeps): Promise<string>;
82
107
  export declare function loadFromSourceJar(svc: SourceService, sourceJarPath: string): Promise<IndexedFileRecord[]>;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, open, rename, stat, unlink } from "node:fs/promises";
4
- import { join } from "node:path";
3
+ import { mkdir, open, rename, rm, stat } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
5
  import { buildSuggestedCall } from "../build-suggested-call.js";
6
6
  import { decompileBinaryJar } from "../decompiler/vineflower.js";
7
7
  import { ERROR_CODES, createError, isAppError } from "../errors.js";
@@ -15,6 +15,13 @@ import { resolveVineflowerJar } from "../vineflower-resolver.js";
15
15
  import { enforceCacheLimits, recordRemappedJarBytes, releaseRemappedJarBytes, touchCacheMetrics, upsertCacheMetrics } from "./cache-metrics.js";
16
16
  import { normalizePathStyle } from "./shared-utils.js";
17
17
  export const INDEX_SCHEMA_VERSION = 1;
18
+ const defaultBinaryRemapDeps = {
19
+ resolveTinyRemapperJar,
20
+ resolveMojangTinyFile,
21
+ remapJar,
22
+ now: () => Date.now(),
23
+ randomSuffix: () => Math.random().toString(36).slice(2, 8) || "0"
24
+ };
18
25
  function chunkArray(items, chunkSize) {
19
26
  const size = Math.max(1, Math.trunc(chunkSize));
20
27
  if (items.length === 0) {
@@ -36,8 +43,8 @@ export async function indexArtifact(svc, input) {
36
43
  }
37
44
  const artifact = svc.getArtifact(artifactId);
38
45
  const force = input.force ?? false;
39
- const hasFiles = svc.filesRepo.listFiles(artifact.artifactId, { limit: 1 }).items.length > 0;
40
46
  const meta = svc.indexMetaRepo.get(artifact.artifactId);
47
+ const hasFiles = meta ? meta.filesCount > 0 : false;
41
48
  const expectedSignature = artifact.artifactSignature ?? fallbackArtifactSignature(artifact.artifactId);
42
49
  const reason = resolveIndexRebuildReason({
43
50
  force,
@@ -284,8 +291,11 @@ export function getArtifact(svc, artifactId) {
284
291
  }
285
292
  export async function ingestIfNeeded(svc, resolved) {
286
293
  const existing = svc.artifactsRepo.getArtifact(resolved.artifactId);
287
- const hasFiles = svc.filesRepo.listFiles(resolved.artifactId, { limit: 1 }).items.length > 0;
288
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;
289
299
  const reason = resolveIndexRebuildReason({
290
300
  force: false,
291
301
  expectedSignature: resolved.artifactSignature,
@@ -354,13 +364,12 @@ async function rebuildMissingArtifactIndex(svc, resolved, reason) {
354
364
  * binary remap, run tiny-remapper now and return the remapped jar path.
355
365
  * Otherwise return the original binaryJarPath unchanged.
356
366
  *
357
- * Cache safety: writes to a per-attempt temp file then atomic-renames into
358
- * <cacheDir>/remapped/<artifactId>.jar. A per-target inflight Promise map
359
- * collapses concurrent calls so two simultaneous resolveArtifact calls for
360
- * the same artifactId share one tiny-remapper run instead of racing on the
361
- * same output path.
367
+ * Cache safety: writes to a per-attempt `.jar` temp path, validates ZIP magic,
368
+ * then atomic-renames into <cacheDir>/remapped/<artifactId>.jar. A per-target
369
+ * inflight Promise map collapses concurrent calls so simultaneous
370
+ * resolveArtifact calls for the same artifactId share one tiny-remapper run.
362
371
  */
363
- export async function maybeRemapBinaryForMojang(svc, resolved) {
372
+ export async function maybeRemapBinaryForMojang(svc, resolved, deps = defaultBinaryRemapDeps) {
364
373
  const binaryJarPath = resolved.binaryJarPath;
365
374
  if (!binaryJarPath) {
366
375
  throw createError({
@@ -400,10 +409,19 @@ export async function maybeRemapBinaryForMojang(svc, resolved) {
400
409
  remappedJarPath
401
410
  });
402
411
  try {
403
- await unlink(remappedJarPath);
412
+ await rm(remappedJarPath, { recursive: true, force: true });
404
413
  }
405
- catch {
406
- // ignore: race with another process or already-deleted file.
414
+ catch (caughtError) {
415
+ releaseRemappedJarBytes(svc, resolved.artifactId);
416
+ throw createError({
417
+ code: ERROR_CODES.REMAP_FAILED,
418
+ message: "Failed to remove corrupt binary remap cache entry.",
419
+ details: {
420
+ artifactId: resolved.artifactId,
421
+ remappedJarPath,
422
+ cause: caughtError instanceof Error ? caughtError.message : String(caughtError)
423
+ }
424
+ });
407
425
  }
408
426
  releaseRemappedJarBytes(svc, resolved.artifactId);
409
427
  }
@@ -411,12 +429,12 @@ export async function maybeRemapBinaryForMojang(svc, resolved) {
411
429
  if (inflight) {
412
430
  return inflight;
413
431
  }
414
- const remapPromise = runBinaryRemap(svc, {
432
+ const remapPromise = runBinaryRemapWithDeps(svc, {
415
433
  version: resolved.version,
416
434
  inputJar: binaryJarPath,
417
435
  remappedDir,
418
436
  remappedJarPath
419
- });
437
+ }, deps);
420
438
  svc.state.inflightRemaps.set(remappedJarPath, remapPromise);
421
439
  try {
422
440
  const path = await remapPromise;
@@ -430,6 +448,9 @@ export async function maybeRemapBinaryForMojang(svc, resolved) {
430
448
  export async function recordRemappedJarBytesFromDisk(svc, artifactId, path) {
431
449
  try {
432
450
  const fileStat = await stat(path);
451
+ if (!fileStat.isFile()) {
452
+ return;
453
+ }
433
454
  recordRemappedJarBytes(svc, artifactId, fileStat.size);
434
455
  }
435
456
  catch {
@@ -467,14 +488,45 @@ export async function isUsableJarFile(path) {
467
488
  await handle?.close().catch(() => undefined);
468
489
  }
469
490
  }
491
+ export function buildBinaryRemapTempPath(remappedJarPath, input) {
492
+ // Keep the final extension as `.jar`; the remap output is rejected unless it
493
+ // is a regular ZIP/JAR file, and manage-cache recognizes this temp shape.
494
+ const fileName = basename(remappedJarPath);
495
+ const artifactId = fileName.endsWith(".jar")
496
+ ? fileName.slice(0, -".jar".length)
497
+ : fileName;
498
+ return join(dirname(remappedJarPath), `${artifactId}.tmp.${input.pid}.${input.now}.${input.randomSuffix}.jar`);
499
+ }
500
+ async function describePathKind(path) {
501
+ try {
502
+ const stats = await stat(path);
503
+ if (stats.isFile()) {
504
+ return "file";
505
+ }
506
+ if (stats.isDirectory()) {
507
+ return "directory";
508
+ }
509
+ return "other";
510
+ }
511
+ catch {
512
+ return "missing";
513
+ }
514
+ }
470
515
  export async function runBinaryRemap(svc, input) {
471
- const tinyRemapperJarPath = await resolveTinyRemapperJar(svc.config.cacheDir, svc.config.tinyRemapperJarPath);
472
- const mojangTiny = await resolveMojangTinyFile(input.version, svc.config);
516
+ return runBinaryRemapWithDeps(svc, input, defaultBinaryRemapDeps);
517
+ }
518
+ export async function runBinaryRemapWithDeps(svc, input, deps) {
519
+ const tinyRemapperJarPath = await deps.resolveTinyRemapperJar(svc.config.cacheDir, svc.config.tinyRemapperJarPath);
520
+ const mojangTiny = await deps.resolveMojangTinyFile(input.version, svc.config);
473
521
  await mkdir(input.remappedDir, { recursive: true });
474
- const tempPath = `${input.remappedJarPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
522
+ const tempPath = buildBinaryRemapTempPath(input.remappedJarPath, {
523
+ pid: process.pid,
524
+ now: deps.now(),
525
+ randomSuffix: deps.randomSuffix()
526
+ });
475
527
  const remapStartedAt = Date.now();
476
528
  try {
477
- await remapJar(tinyRemapperJarPath, {
529
+ await deps.remapJar(tinyRemapperJarPath, {
478
530
  inputJar: input.inputJar,
479
531
  outputJar: tempPath,
480
532
  mappingsFile: mojangTiny.path,
@@ -483,20 +535,38 @@ export async function runBinaryRemap(svc, input) {
483
535
  timeoutMs: svc.config.remapTimeoutMs,
484
536
  maxMemoryMb: svc.config.remapMaxMemoryMb
485
537
  });
486
- const tempStats = await stat(tempPath);
487
- if (tempStats.size === 0) {
538
+ if (!(await isUsableJarFile(tempPath))) {
539
+ throw createError({
540
+ code: ERROR_CODES.REMAP_FAILED,
541
+ message: "tiny-remapper produced an invalid output jar.",
542
+ details: {
543
+ inputJar: input.inputJar,
544
+ tempPath,
545
+ outputKind: await describePathKind(tempPath)
546
+ }
547
+ });
548
+ }
549
+ try {
550
+ await rename(tempPath, input.remappedJarPath);
551
+ }
552
+ catch (caughtError) {
488
553
  throw createError({
489
554
  code: ERROR_CODES.REMAP_FAILED,
490
- message: "tiny-remapper produced an empty output jar.",
491
- details: { inputJar: input.inputJar, tempPath }
555
+ message: "Failed to finalize binary remap cache entry.",
556
+ details: {
557
+ inputJar: input.inputJar,
558
+ tempPath,
559
+ remappedJarPath: input.remappedJarPath,
560
+ outputKind: await describePathKind(input.remappedJarPath),
561
+ cause: caughtError instanceof Error ? caughtError.message : String(caughtError)
562
+ }
492
563
  });
493
564
  }
494
- await rename(tempPath, input.remappedJarPath);
495
565
  return input.remappedJarPath;
496
566
  }
497
567
  catch (caughtError) {
498
568
  try {
499
- await unlink(tempPath);
569
+ await rm(tempPath, { recursive: true, force: true });
500
570
  }
501
571
  catch {
502
572
  // tempPath may not exist if remapJar failed before writing anything; ignore.
@@ -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;