@adhisang/minecraft-modding-mcp 2.0.0 → 3.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 (57) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +139 -30
  3. package/dist/cache-registry.d.ts +95 -0
  4. package/dist/cache-registry.js +541 -0
  5. package/dist/cli.js +31 -4
  6. package/dist/compat-stdio-transport.d.ts +2 -7
  7. package/dist/compat-stdio-transport.js +12 -154
  8. package/dist/entry-tools/analyze-mod-service.d.ts +207 -0
  9. package/dist/entry-tools/analyze-mod-service.js +253 -0
  10. package/dist/entry-tools/analyze-symbol-service.d.ts +209 -0
  11. package/dist/entry-tools/analyze-symbol-service.js +304 -0
  12. package/dist/entry-tools/compare-minecraft-service.d.ts +210 -0
  13. package/dist/entry-tools/compare-minecraft-service.js +397 -0
  14. package/dist/entry-tools/entry-tool-schema.d.ts +6 -0
  15. package/dist/entry-tools/entry-tool-schema.js +10 -0
  16. package/dist/entry-tools/inspect-minecraft-service.d.ts +1953 -0
  17. package/dist/entry-tools/inspect-minecraft-service.js +876 -0
  18. package/dist/entry-tools/manage-cache-service.d.ts +130 -0
  19. package/dist/entry-tools/manage-cache-service.js +229 -0
  20. package/dist/entry-tools/request-normalizers.d.ts +10 -0
  21. package/dist/entry-tools/request-normalizers.js +36 -0
  22. package/dist/entry-tools/response-contract.d.ts +44 -0
  23. package/dist/entry-tools/response-contract.js +96 -0
  24. package/dist/entry-tools/validate-project-service.d.ts +543 -0
  25. package/dist/entry-tools/validate-project-service.js +381 -0
  26. package/dist/index.js +495 -42
  27. package/dist/json-rpc-framing.d.ts +22 -0
  28. package/dist/json-rpc-framing.js +168 -0
  29. package/dist/mapping-pipeline-service.js +9 -1
  30. package/dist/mapping-service.d.ts +9 -0
  31. package/dist/mapping-service.js +183 -60
  32. package/dist/minecraft-explorer-service.d.ts +0 -1
  33. package/dist/minecraft-explorer-service.js +119 -23
  34. package/dist/mixin-validator.d.ts +24 -2
  35. package/dist/mixin-validator.js +223 -98
  36. package/dist/mod-decompile-service.d.ts +5 -0
  37. package/dist/mod-decompile-service.js +40 -5
  38. package/dist/mod-remap-service.js +142 -30
  39. package/dist/path-resolver.js +41 -4
  40. package/dist/registry-service.d.ts +10 -1
  41. package/dist/registry-service.js +154 -22
  42. package/dist/search-hit-accumulator.js +23 -2
  43. package/dist/source-jar-reader.js +16 -2
  44. package/dist/source-resolver.js +6 -7
  45. package/dist/source-service.d.ts +42 -4
  46. package/dist/source-service.js +781 -127
  47. package/dist/stdio-supervisor.d.ts +46 -0
  48. package/dist/stdio-supervisor.js +349 -0
  49. package/dist/storage/files-repo.d.ts +3 -9
  50. package/dist/storage/files-repo.js +66 -43
  51. package/dist/symbols/symbol-extractor.js +6 -4
  52. package/dist/tool-execution-gate.d.ts +15 -0
  53. package/dist/tool-execution-gate.js +58 -0
  54. package/dist/version-diff-service.js +10 -5
  55. package/dist/version-service.js +7 -2
  56. package/dist/workspace-mapping-service.js +12 -0
  57. package/package.json +1 -1
@@ -1,19 +1,20 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, statSync, rmSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { createError, ERROR_CODES } from "./errors.js";
5
+ import { createError, ERROR_CODES, isAppError } from "./errors.js";
6
6
  import { log } from "./logger.js";
7
7
  import { resolveTinyMappingFile } from "./mapping-service.js";
8
8
  import { resolveMojangTinyFile } from "./mojang-tiny-mapping-service.js";
9
9
  import { analyzeModJar } from "./mod-analyzer.js";
10
10
  import { normalizePathForHost } from "./path-converter.js";
11
+ import { listJarEntries, readJarEntryAsBuffer } from "./source-jar-reader.js";
11
12
  import { remapJar } from "./tiny-remapper-service.js";
12
13
  import { resolveTinyRemapperJar } from "./tiny-remapper-resolver.js";
13
14
  function normalizeTargetNamespace(target) {
14
15
  return target === "yarn" ? "yarn" : "mojang";
15
16
  }
16
- function sourceNamespaceForLoader(loader) {
17
+ function defaultSourceNamespaceForLoader(loader) {
17
18
  if (loader === "fabric" || loader === "quilt") {
18
19
  return "intermediary";
19
20
  }
@@ -35,6 +36,81 @@ function extractMinecraftVersion(dependencies) {
35
36
  const match = mcDep.versionRange.match(/(\d+\.\d+(?:\.\d+)?)/);
36
37
  return match?.[1];
37
38
  }
39
+ function countMatches(input, pattern) {
40
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
41
+ const globalPattern = new RegExp(pattern.source, flags);
42
+ let count = 0;
43
+ while (globalPattern.exec(input)) {
44
+ count += 1;
45
+ }
46
+ return count;
47
+ }
48
+ async function detectFabricLikeInputNamespace(inputJar) {
49
+ const warnings = [];
50
+ const classEntries = (await listJarEntries(inputJar))
51
+ .filter((entry) => entry.endsWith(".class"))
52
+ .slice(0, 24);
53
+ if (classEntries.length === 0) {
54
+ warnings.push("Could not inspect class entries to detect input mapping; assuming intermediary.");
55
+ return {
56
+ fromNamespace: "intermediary",
57
+ warnings
58
+ };
59
+ }
60
+ let mojangScore = 0;
61
+ let intermediaryScore = 0;
62
+ for (const entry of classEntries) {
63
+ let text = "";
64
+ try {
65
+ text = (await readJarEntryAsBuffer(inputJar, entry)).toString("latin1");
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ mojangScore += countMatches(text, /net\/minecraft\/(?:advancements|client|commands|core|data|gametest|nbt|network|recipe|resources|server|sounds|stats|tags|util|world)\//g) * 3;
71
+ intermediaryScore += countMatches(text, /net\/minecraft\/class_\d+/g) * 3;
72
+ intermediaryScore += countMatches(text, /\b(?:method|field)_\d+\b/g);
73
+ }
74
+ if (mojangScore > intermediaryScore && mojangScore > 0) {
75
+ return {
76
+ fromNamespace: "mojang",
77
+ warnings
78
+ };
79
+ }
80
+ if (intermediaryScore > mojangScore && intermediaryScore > 0) {
81
+ return {
82
+ fromNamespace: "intermediary",
83
+ warnings
84
+ };
85
+ }
86
+ warnings.push("Could not confidently detect whether the input jar uses intermediary or mojang names; assuming intermediary.");
87
+ return {
88
+ fromNamespace: "intermediary",
89
+ warnings
90
+ };
91
+ }
92
+ async function detectInputNamespaceForLoader(inputJar, loader) {
93
+ if (loader === "fabric" || loader === "quilt") {
94
+ return detectFabricLikeInputNamespace(inputJar);
95
+ }
96
+ return {
97
+ fromNamespace: defaultSourceNamespaceForLoader(loader),
98
+ warnings: []
99
+ };
100
+ }
101
+ function resolveOutputJarPath(input, normalizedInput, modId, modVersion) {
102
+ const defaultOutputName = `${modId ?? "mod"}-${modVersion ?? "0"}-${input.targetMapping}.jar`;
103
+ return input.outputJar
104
+ ? normalizePathForHost(input.outputJar, undefined, "outputJar")
105
+ : join(dirname(normalizedInput), defaultOutputName);
106
+ }
107
+ function copyJarToDestination(sourceJar, destinationJar) {
108
+ if (sourceJar === destinationJar) {
109
+ return;
110
+ }
111
+ mkdirSync(dirname(destinationJar), { recursive: true });
112
+ copyFileSync(sourceJar, destinationJar);
113
+ }
38
114
  function buildCacheKey(inputJar, fromNamespace, targetNamespace, mcVersion) {
39
115
  const stat = statSync(inputJar, { throwIfNoEntry: false });
40
116
  const signature = stat ? `${stat.mtimeMs}:${stat.size}` : "unknown";
@@ -71,7 +147,9 @@ export async function remapModJar(input, config) {
71
147
  details: { inputJar: normalizedInput }
72
148
  });
73
149
  }
74
- const fromNamespace = sourceNamespaceForLoader(analysis.loader);
150
+ const namespaceDetection = await detectInputNamespaceForLoader(normalizedInput, analysis.loader);
151
+ warnings.push(...namespaceDetection.warnings);
152
+ const fromNamespace = namespaceDetection.fromNamespace;
75
153
  // 3. Determine MC version
76
154
  const mcVersion = input.mcVersion ?? extractMinecraftVersion(analysis.dependencies);
77
155
  if (!mcVersion) {
@@ -85,21 +163,32 @@ export async function remapModJar(input, config) {
85
163
  }
86
164
  });
87
165
  }
166
+ const outputJar = resolveOutputJarPath(input, normalizedInput, analysis.modId, analysis.modVersion);
88
167
  // 4. Check cache after mapping context is known
89
168
  const cacheKey = buildCacheKey(normalizedInput, fromNamespace, resolvedTargetNamespace, mcVersion);
90
169
  const cacheDir = join(config.cacheDir, "remapped-mods");
91
170
  mkdirSync(cacheDir, { recursive: true });
92
171
  const cachedOutput = join(cacheDir, `${cacheKey}.jar`);
93
172
  if (existsSync(cachedOutput)) {
94
- const outputJar = input.outputJar
95
- ? normalizePathForHost(input.outputJar, undefined, "outputJar")
173
+ const cacheHitOutputJar = input.outputJar
174
+ ? outputJar
96
175
  : cachedOutput;
97
- if (outputJar !== cachedOutput) {
98
- const { copyFileSync } = await import("node:fs");
99
- mkdirSync(dirname(outputJar), { recursive: true });
100
- copyFileSync(cachedOutput, outputJar);
101
- }
102
- log("info", "remap.cache-hit", { inputJar: normalizedInput, outputJar });
176
+ copyJarToDestination(cachedOutput, cacheHitOutputJar);
177
+ log("info", "remap.cache-hit", { inputJar: normalizedInput, outputJar: cacheHitOutputJar });
178
+ return {
179
+ outputJar: cacheHitOutputJar,
180
+ mcVersion,
181
+ fromMapping: fromNamespace,
182
+ targetMapping: input.targetMapping,
183
+ resolvedTargetNamespace,
184
+ durationMs: Date.now() - startedAt,
185
+ warnings: [...warnings, "Result served from cache."]
186
+ };
187
+ }
188
+ if (fromNamespace === resolvedTargetNamespace) {
189
+ copyJarToDestination(normalizedInput, outputJar);
190
+ copyJarToDestination(normalizedInput, cachedOutput);
191
+ warnings.push(`Input JAR already uses ${fromNamespace} names; output is a copy of the input JAR.`);
103
192
  return {
104
193
  outputJar,
105
194
  mcVersion,
@@ -107,9 +196,23 @@ export async function remapModJar(input, config) {
107
196
  targetMapping: input.targetMapping,
108
197
  resolvedTargetNamespace,
109
198
  durationMs: Date.now() - startedAt,
110
- warnings: ["Result served from cache."]
199
+ warnings
111
200
  };
112
201
  }
202
+ if (fromNamespace === "mojang" && resolvedTargetNamespace === "yarn") {
203
+ throw createError({
204
+ code: ERROR_CODES.REMAP_FAILED,
205
+ message: "Mojang-mapped Fabric/Quilt input jars cannot be remapped to yarn with the available mapping files.",
206
+ details: {
207
+ inputJar: normalizedInput,
208
+ mcVersion,
209
+ fromMapping: fromNamespace,
210
+ targetMapping: input.targetMapping,
211
+ resolvedTargetNamespace,
212
+ nextAction: 'Use targetMapping="mojang" for Mojang-mapped inputs, or rebuild the mod against intermediary mappings before requesting yarn output.'
213
+ }
214
+ });
215
+ }
113
216
  // 5. Resolve tiny-remapper
114
217
  const tinyRemapperJar = await resolveTinyRemapperJar(config.cacheDir, config.tinyRemapperJarPath);
115
218
  // 6. Resolve mapping file and remap
@@ -125,30 +228,39 @@ export async function remapModJar(input, config) {
125
228
  toNamespace = "mojang";
126
229
  warnings.push(...mojangTiny.warnings);
127
230
  }
128
- // 7. Determine output path
129
- const modId = analysis.modId ?? "mod";
130
- const modVersion = analysis.modVersion ?? "0";
131
- const defaultOutputName = `${modId}-${modVersion}-${input.targetMapping}.jar`;
132
- const outputJar = input.outputJar
133
- ? normalizePathForHost(input.outputJar, undefined, "outputJar")
134
- : join(dirname(normalizedInput), defaultOutputName);
135
231
  mkdirSync(dirname(outputJar), { recursive: true });
136
232
  // 8. Use temporary directory for intermediate work
137
233
  const tempDir = join(tmpdir(), `mcp-remap-${cacheKey.slice(0, 12)}`);
138
234
  mkdirSync(tempDir, { recursive: true });
139
235
  try {
140
236
  const tempOutput = join(tempDir, "remapped.jar");
141
- await remapJar(tinyRemapperJar, {
142
- inputJar: normalizedInput,
143
- outputJar: tempOutput,
144
- mappingsFile,
145
- fromNamespace,
146
- toNamespace,
147
- timeoutMs: config.remapTimeoutMs,
148
- maxMemoryMb: config.remapMaxMemoryMb
149
- });
237
+ try {
238
+ await remapJar(tinyRemapperJar, {
239
+ inputJar: normalizedInput,
240
+ outputJar: tempOutput,
241
+ mappingsFile,
242
+ fromNamespace,
243
+ toNamespace,
244
+ timeoutMs: config.remapTimeoutMs,
245
+ maxMemoryMb: config.remapMaxMemoryMb
246
+ });
247
+ }
248
+ catch (caughtError) {
249
+ if (isAppError(caughtError)) {
250
+ throw createError({
251
+ code: caughtError.code,
252
+ message: caughtError.message,
253
+ details: {
254
+ ...(caughtError.details ?? {}),
255
+ fromMapping: fromNamespace,
256
+ targetMapping: input.targetMapping,
257
+ resolvedTargetNamespace
258
+ }
259
+ });
260
+ }
261
+ throw caughtError;
262
+ }
150
263
  // Copy to final destination and cache
151
- const { copyFileSync } = await import("node:fs");
152
264
  copyFileSync(tempOutput, outputJar);
153
265
  if (outputJar !== cachedOutput) {
154
266
  mkdirSync(dirname(cachedOutput), { recursive: true });
@@ -2,12 +2,35 @@ import { realpathSync, statSync } from "node:fs";
2
2
  import { extname, resolve } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { normalizePathForHost } from "./path-converter.js";
5
- import { createError, ERROR_CODES } from "./errors.js";
5
+ import { createError, ERROR_CODES, isAppError } from "./errors.js";
6
6
  const INVALID_ENTRY = /(^|\/|\\)\.\.(\/|\\|$)/;
7
7
  export function normalizeJarPath(jarPath) {
8
8
  const normalizedInput = normalizePathForHost(jarPath, undefined, "jarPath");
9
9
  const absolute = resolve(normalizedInput);
10
- const stats = statSync(absolute);
10
+ let stats;
11
+ try {
12
+ stats = statSync(absolute, { throwIfNoEntry: false });
13
+ }
14
+ catch (cause) {
15
+ if (isAppError(cause)) {
16
+ throw cause;
17
+ }
18
+ throw createError({
19
+ code: ERROR_CODES.JAR_NOT_FOUND,
20
+ message: `Could not access jar "${normalizedInput}".`,
21
+ details: {
22
+ jarPath: normalizedInput,
23
+ reason: cause instanceof Error ? cause.message : String(cause)
24
+ }
25
+ });
26
+ }
27
+ if (!stats) {
28
+ throw createError({
29
+ code: ERROR_CODES.JAR_NOT_FOUND,
30
+ message: `Jar not found: "${normalizedInput}".`,
31
+ details: { jarPath: normalizedInput }
32
+ });
33
+ }
11
34
  if (!stats.isFile()) {
12
35
  throw createError({
13
36
  code: ERROR_CODES.JAR_NOT_FOUND,
@@ -22,8 +45,22 @@ export function normalizeJarPath(jarPath) {
22
45
  details: { jarPath: normalizedInput }
23
46
  });
24
47
  }
25
- const resolved = realpathSync(absolute);
26
- return resolved;
48
+ try {
49
+ return realpathSync(absolute);
50
+ }
51
+ catch (cause) {
52
+ if (isAppError(cause)) {
53
+ throw cause;
54
+ }
55
+ throw createError({
56
+ code: ERROR_CODES.JAR_NOT_FOUND,
57
+ message: `Could not resolve jar "${normalizedInput}".`,
58
+ details: {
59
+ jarPath: normalizedInput,
60
+ reason: cause instanceof Error ? cause.message : String(cause)
61
+ }
62
+ });
63
+ }
27
64
  }
28
65
  export function resolveJarPathWithSymlinkCheck(jarPath) {
29
66
  const resolvedPath = normalizeJarPath(jarPath);
@@ -3,6 +3,8 @@ import type { Config } from "./types.js";
3
3
  export type GetRegistryDataInput = {
4
4
  version: string;
5
5
  registry?: string;
6
+ includeData?: boolean;
7
+ maxEntriesPerRegistry?: number;
6
8
  };
7
9
  export type RegistryEntry = {
8
10
  protocol_id: number;
@@ -15,8 +17,11 @@ export type GetRegistryDataOutput = {
15
17
  version: string;
16
18
  registry?: string;
17
19
  registries?: string[];
18
- data: Record<string, RegistryData> | RegistryData;
20
+ data?: Record<string, RegistryData> | RegistryData;
19
21
  entryCount: number;
22
+ returnedEntryCount?: number;
23
+ registryEntryCounts?: Record<string, number>;
24
+ dataTruncated?: boolean;
20
25
  warnings: string[];
21
26
  };
22
27
  export declare class RegistryService {
@@ -26,4 +31,8 @@ export declare class RegistryService {
26
31
  constructor(config: Config, versionService: VersionService);
27
32
  getRegistryData(input: GetRegistryDataInput): Promise<GetRegistryDataOutput>;
28
33
  private loadRegistries;
34
+ private loadExistingRegistries;
35
+ private readRegistryFileOrThrow;
36
+ private isInvalidRegistrySnapshot;
37
+ private cacheRegistries;
29
38
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, rmSync } from "node:fs";
2
2
  import { mkdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { spawn } from "node:child_process";
@@ -6,6 +6,40 @@ import { createError, ERROR_CODES } from "./errors.js";
6
6
  import { log } from "./logger.js";
7
7
  const DATAGEN_TIMEOUT_MS = 5 * 60 * 1000;
8
8
  const MAX_STDIO_SNAPSHOT = 6_240;
9
+ function clampPositiveInt(value) {
10
+ if (!Number.isFinite(value) || value == null) {
11
+ return undefined;
12
+ }
13
+ return Math.max(1, Math.trunc(value));
14
+ }
15
+ function sortedRegistryEntryNames(entries) {
16
+ return Object.keys(entries).sort((left, right) => left.localeCompare(right));
17
+ }
18
+ function limitRegistryEntries(data, maxEntries) {
19
+ const entryNames = sortedRegistryEntryNames(data.entries);
20
+ const total = entryNames.length;
21
+ if (maxEntries == null || total <= maxEntries) {
22
+ return {
23
+ data,
24
+ total,
25
+ returned: total,
26
+ truncated: false
27
+ };
28
+ }
29
+ const limitedEntries = {};
30
+ for (const entryName of entryNames.slice(0, maxEntries)) {
31
+ limitedEntries[entryName] = data.entries[entryName];
32
+ }
33
+ return {
34
+ data: {
35
+ default: data.default,
36
+ entries: limitedEntries
37
+ },
38
+ total,
39
+ returned: maxEntries,
40
+ truncated: true
41
+ };
42
+ }
9
43
  function limitOutput(text) {
10
44
  if (text.length <= MAX_STDIO_SNAPSHOT)
11
45
  return text;
@@ -25,6 +59,31 @@ function findRegistryFile(registryDir) {
25
59
  }
26
60
  return undefined;
27
61
  }
62
+ function parseRegistrySnapshot(raw, version, registryFile) {
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(raw);
66
+ }
67
+ catch (error) {
68
+ throw createError({
69
+ code: ERROR_CODES.REGISTRY_GENERATION_FAILED,
70
+ message: `Failed to parse registries.json for version "${version}".`,
71
+ details: {
72
+ version,
73
+ registryFile,
74
+ reason: error instanceof Error ? error.message : String(error)
75
+ }
76
+ });
77
+ }
78
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
79
+ throw createError({
80
+ code: ERROR_CODES.REGISTRY_GENERATION_FAILED,
81
+ message: `registries.json for version "${version}" has invalid structure.`,
82
+ details: { version, registryFile }
83
+ });
84
+ }
85
+ return parsed;
86
+ }
28
87
  function runDataGen(serverJarPath, outputDir, version) {
29
88
  return new Promise((resolve, reject) => {
30
89
  // MC 1.18+ uses bundler format, older versions use -cp with main class directly.
@@ -131,6 +190,12 @@ export class RegistryService {
131
190
  const warnings = [];
132
191
  const allRegistries = await this.loadRegistries(version, warnings);
133
192
  const registryNames = Object.keys(allRegistries).sort();
193
+ const includeData = input.includeData ?? true;
194
+ const maxEntriesPerRegistry = clampPositiveInt(input.maxEntriesPerRegistry);
195
+ const registryEntryCounts = Object.fromEntries(registryNames.map((registryName) => [
196
+ registryName,
197
+ Object.keys(allRegistries[registryName].entries).length
198
+ ]));
134
199
  if (input.registry) {
135
200
  const registryName = normalizeRegistryName(input.registry);
136
201
  const data = allRegistries[registryName];
@@ -145,11 +210,17 @@ export class RegistryService {
145
210
  }
146
211
  });
147
212
  }
213
+ const limited = limitRegistryEntries(data, maxEntriesPerRegistry);
148
214
  return {
149
215
  version,
150
216
  registry: registryName,
151
- data,
152
- entryCount: Object.keys(data.entries).length,
217
+ ...(includeData ? { data: limited.data } : {}),
218
+ entryCount: limited.total,
219
+ returnedEntryCount: includeData ? limited.returned : 0,
220
+ registryEntryCounts: {
221
+ [registryName]: limited.total
222
+ },
223
+ ...(limited.truncated ? { dataTruncated: true } : {}),
153
224
  warnings
154
225
  };
155
226
  }
@@ -157,11 +228,25 @@ export class RegistryService {
157
228
  for (const registry of Object.values(allRegistries)) {
158
229
  totalEntries += Object.keys(registry.entries).length;
159
230
  }
231
+ let returnedEntryCount = 0;
232
+ let dataTruncated = false;
233
+ const limitedData = {};
234
+ if (includeData) {
235
+ for (const registryName of registryNames) {
236
+ const limited = limitRegistryEntries(allRegistries[registryName], maxEntriesPerRegistry);
237
+ limitedData[registryName] = limited.data;
238
+ returnedEntryCount += limited.returned;
239
+ dataTruncated ||= limited.truncated;
240
+ }
241
+ }
160
242
  return {
161
243
  version,
162
244
  registries: registryNames,
163
- data: allRegistries,
245
+ ...(includeData ? { data: limitedData } : {}),
164
246
  entryCount: totalEntries,
247
+ returnedEntryCount: includeData ? returnedEntryCount : 0,
248
+ registryEntryCounts,
249
+ ...(dataTruncated ? { dataTruncated: true } : {}),
165
250
  warnings
166
251
  };
167
252
  }
@@ -170,31 +255,79 @@ export class RegistryService {
170
255
  if (cached)
171
256
  return cached;
172
257
  const registryDir = join(this.config.cacheDir, "registries", version);
173
- // Check if we already have generated data
174
- let registryFile = findRegistryFile(registryDir);
258
+ const cachedRegistries = this.loadExistingRegistries(registryDir, version, warnings);
259
+ if (cachedRegistries) {
260
+ this.cacheRegistries(version, cachedRegistries);
261
+ return cachedRegistries;
262
+ }
263
+ await mkdir(registryDir, { recursive: true });
264
+ const serverJar = await this.versionService.resolveServerJar(version);
265
+ await runDataGen(serverJar.jarPath, registryDir, version);
266
+ const registryFile = findRegistryFile(registryDir);
175
267
  if (!registryFile) {
176
- await mkdir(registryDir, { recursive: true });
177
- const serverJar = await this.versionService.resolveServerJar(version);
178
- await runDataGen(serverJar.jarPath, registryDir, version);
179
- registryFile = findRegistryFile(registryDir);
180
- if (!registryFile) {
181
- throw createError({
182
- code: ERROR_CODES.REGISTRY_GENERATION_FAILED,
183
- message: `Registry data generation did not produce registries.json for version "${version}".`,
184
- details: { version, registryDir }
268
+ throw createError({
269
+ code: ERROR_CODES.REGISTRY_GENERATION_FAILED,
270
+ message: `Registry data generation did not produce registries.json for version "${version}".`,
271
+ details: { version, registryDir }
272
+ });
273
+ }
274
+ const parsed = this.readRegistryFileOrThrow(registryFile, version);
275
+ this.cacheRegistries(version, parsed);
276
+ return parsed;
277
+ }
278
+ loadExistingRegistries(registryDir, version, warnings) {
279
+ for (const candidate of resolveRegistryPaths(registryDir)) {
280
+ if (!existsSync(candidate)) {
281
+ continue;
282
+ }
283
+ try {
284
+ return this.readRegistryFileOrThrow(candidate, version);
285
+ }
286
+ catch (error) {
287
+ if (!this.isInvalidRegistrySnapshot(error)) {
288
+ throw error;
289
+ }
290
+ warnings.push(`Ignored corrupt cached registry snapshot and regenerated it: ${candidate}`);
291
+ log("warn", "registry.cache.invalidated", {
292
+ version,
293
+ registryFile: candidate,
294
+ reason: error.message
185
295
  });
296
+ try {
297
+ rmSync(candidate, { force: true });
298
+ }
299
+ catch {
300
+ // best-effort cleanup
301
+ }
186
302
  }
187
303
  }
188
- const raw = readFileSync(registryFile, "utf8");
189
- const parsed = JSON.parse(raw);
190
- // Validate structure
191
- if (typeof parsed !== "object" || parsed === null) {
304
+ return undefined;
305
+ }
306
+ readRegistryFileOrThrow(registryFile, version) {
307
+ let raw;
308
+ try {
309
+ raw = readFileSync(registryFile, "utf8");
310
+ }
311
+ catch (error) {
192
312
  throw createError({
193
313
  code: ERROR_CODES.REGISTRY_GENERATION_FAILED,
194
- message: `registries.json for version "${version}" has invalid structure.`,
195
- details: { version }
314
+ message: `Failed to read registries.json for version "${version}".`,
315
+ details: {
316
+ version,
317
+ registryFile,
318
+ reason: error instanceof Error ? error.message : String(error)
319
+ }
196
320
  });
197
321
  }
322
+ return parseRegistrySnapshot(raw, version, registryFile);
323
+ }
324
+ isInvalidRegistrySnapshot(error) {
325
+ return (typeof error === "object" &&
326
+ error !== null &&
327
+ "code" in error &&
328
+ error.code === ERROR_CODES.REGISTRY_GENERATION_FAILED);
329
+ }
330
+ cacheRegistries(version, parsed) {
198
331
  this.registryCache.set(version, parsed);
199
332
  // Trim cache to avoid unbounded growth
200
333
  if (this.registryCache.size > 8) {
@@ -202,7 +335,6 @@ export class RegistryService {
202
335
  if (oldest !== undefined)
203
336
  this.registryCache.delete(oldest);
204
337
  }
205
- return parsed;
206
338
  }
207
339
  }
208
340
  function normalizeRegistryName(name) {
@@ -107,6 +107,21 @@ function heapSiftUp(heap, index) {
107
107
  index = parent;
108
108
  }
109
109
  }
110
+ function heapPopWorst(heap) {
111
+ if (heap.length === 0) {
112
+ return undefined;
113
+ }
114
+ const root = heap[0];
115
+ const tail = heap.pop();
116
+ if (heap.length === 0) {
117
+ return root;
118
+ }
119
+ if (tail) {
120
+ heap[0] = tail;
121
+ heapSiftDown(heap, 0, heap.length);
122
+ }
123
+ return root;
124
+ }
110
125
  export function createSearchHitAccumulator(limit, cursor) {
111
126
  const pageLimit = Math.max(1, limit);
112
127
  const keepLimit = pageLimit + 1;
@@ -141,8 +156,14 @@ export function createSearchHitAccumulator(limit, cursor) {
141
156
  return heap.length;
142
157
  },
143
158
  finalize() {
144
- // Sort heap contents by scoreHitOrder (best first)
145
- const sorted = heap.slice().sort(scoreHitOrder);
159
+ const sorted = new Array(heap.length);
160
+ const heapCopy = heap.slice();
161
+ for (let index = heapCopy.length - 1; index >= 0; index -= 1) {
162
+ const next = heapPopWorst(heapCopy);
163
+ if (next) {
164
+ sorted[index] = next;
165
+ }
166
+ }
146
167
  const page = sorted.slice(0, pageLimit);
147
168
  const hasMore = totalAfterCursor > page.length;
148
169
  return {
@@ -9,6 +9,20 @@ function toErrorMessage(value) {
9
9
  }
10
10
  return String(value);
11
11
  }
12
+ function hasJavaSourceExtension(entryPath) {
13
+ const suffix = ".java";
14
+ if (entryPath.length < suffix.length) {
15
+ return false;
16
+ }
17
+ for (let index = 0; index < suffix.length; index += 1) {
18
+ const charCode = entryPath.charCodeAt(entryPath.length - suffix.length + index);
19
+ const normalizedCharCode = charCode >= 65 && charCode <= 90 ? charCode + 32 : charCode;
20
+ if (normalizedCharCode !== suffix.charCodeAt(index)) {
21
+ return false;
22
+ }
23
+ }
24
+ return true;
25
+ }
12
26
  function openZipFile(jarPath) {
13
27
  return new Promise((resolve, reject) => {
14
28
  yauzl.open(jarPath, {
@@ -128,7 +142,7 @@ export async function listJarEntries(jarPath) {
128
142
  }
129
143
  export async function listJavaEntries(jarPath) {
130
144
  const entries = await listJarEntries(jarPath);
131
- return entries.filter((entry) => entry.toLowerCase().endsWith(".java") && isSecureJarEntryPath(entry));
145
+ return entries.filter((entry) => hasJavaSourceExtension(entry) && isSecureJarEntryPath(entry));
132
146
  }
133
147
  export async function readJarEntryAsUtf8(jarPath, entryPath) {
134
148
  const contentBuffer = await readJarEntryAsBuffer(jarPath, entryPath);
@@ -171,7 +185,7 @@ export async function* iterateJavaEntriesAsUtf8(jarPath, maxBytes) {
171
185
  if (!entry) {
172
186
  break;
173
187
  }
174
- if (!entry.fileName.toLowerCase().endsWith(".java")) {
188
+ if (!hasJavaSourceExtension(entry.fileName)) {
175
189
  continue;
176
190
  }
177
191
  if (!isSecureJarEntryPath(entry.fileName)) {
@@ -1,6 +1,7 @@
1
1
  import { readdirSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import fastGlob from "fast-glob";
4
5
  import { createError, ERROR_CODES } from "./errors.js";
5
6
  import { buildRemoteBinaryUrls, buildRemoteSourceUrls, hasExistingJar, parseCoordinate, normalizedCoordinateValue } from "./maven-resolver.js";
6
7
  import { defaultDownloadPath, downloadToCache } from "./repo-downloader.js";
@@ -104,13 +105,11 @@ function resolveGradleCacheCoordinateCandidate(coordinate) {
104
105
  ];
105
106
  let discoveredFiles = [];
106
107
  try {
107
- const hashes = readdirSync(baseDir);
108
- for (const hashDir of hashes) {
109
- const fullDir = join(baseDir, hashDir);
110
- for (const entry of readdirSync(fullDir)) {
111
- discoveredFiles.push(join(fullDir, entry));
112
- }
113
- }
108
+ discoveredFiles = fastGlob.sync("*/*", {
109
+ cwd: baseDir,
110
+ absolute: true,
111
+ onlyFiles: true
112
+ });
114
113
  }
115
114
  catch {
116
115
  return undefined;