@adhisang/minecraft-modding-mcp 7.0.0-rc.3 → 7.1.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 (60) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +3 -2
  3. package/dist/cache-registry.d.ts +16 -0
  4. package/dist/cache-registry.js +78 -10
  5. package/dist/entry-tools/analyze-mod-service.d.ts +2 -2
  6. package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
  7. package/dist/entry-tools/batch-class-members-service.d.ts +3 -2
  8. package/dist/entry-tools/batch-class-members-service.js +20 -6
  9. package/dist/entry-tools/batch-class-source-service.d.ts +3 -2
  10. package/dist/entry-tools/batch-class-source-service.js +10 -0
  11. package/dist/entry-tools/compare-minecraft-service.d.ts +27 -4
  12. package/dist/entry-tools/compare-minecraft-service.js +65 -4
  13. package/dist/entry-tools/entry-tool-schema.d.ts +2 -2
  14. package/dist/entry-tools/inspect-minecraft-service.d.ts +2 -2
  15. package/dist/entry-tools/manage-cache-service.d.ts +2 -2
  16. package/dist/entry-tools/validate-project/cases/project-summary.js +71 -12
  17. package/dist/entry-tools/validate-project-service.d.ts +2 -2
  18. package/dist/index.js +37 -15
  19. package/dist/json-rpc-framing.d.ts +20 -0
  20. package/dist/json-rpc-framing.js +80 -7
  21. package/dist/mapping/loaders/tiny-maven.d.ts +9 -0
  22. package/dist/mapping/loaders/tiny-maven.js +10 -2
  23. package/dist/repo-downloader.js +13 -2
  24. package/dist/source/artifact-resolver.d.ts +14 -0
  25. package/dist/source/artifact-resolver.js +106 -12
  26. package/dist/source/class-source/members-builder.d.ts +7 -0
  27. package/dist/source/class-source/members-builder.js +4 -1
  28. package/dist/source/class-source.d.ts +9 -2
  29. package/dist/source/class-source.js +229 -26
  30. package/dist/source/indexer.js +69 -1
  31. package/dist/source/lifecycle/mapping-helpers.d.ts +20 -1
  32. package/dist/source/lifecycle/mapping-helpers.js +29 -3
  33. package/dist/source/lifecycle/runtime-check.d.ts +25 -0
  34. package/dist/source/lifecycle/runtime-check.js +68 -39
  35. package/dist/source/symbol-resolver.js +88 -0
  36. package/dist/source-jar-reader.d.ts +33 -0
  37. package/dist/source-jar-reader.js +58 -0
  38. package/dist/source-resolver.d.ts +7 -0
  39. package/dist/source-resolver.js +20 -5
  40. package/dist/source-service.d.ts +5 -0
  41. package/dist/source-service.js +7 -0
  42. package/dist/stdio-supervisor.js +193 -34
  43. package/dist/storage/db.d.ts +62 -2
  44. package/dist/storage/db.js +186 -21
  45. package/dist/storage/sqlite.d.ts +31 -1
  46. package/dist/storage/sqlite.js +125 -16
  47. package/dist/tool-guidance.js +4 -1
  48. package/dist/tool-schemas.d.ts +64 -52
  49. package/dist/tool-schemas.js +9 -7
  50. package/dist/types.d.ts +9 -0
  51. package/dist/v1-parity-schemas.js +36 -2
  52. package/dist/version-diff-service.d.ts +23 -0
  53. package/dist/version-diff-service.js +101 -0
  54. package/dist/version-service.d.ts +14 -0
  55. package/dist/version-service.js +45 -3
  56. package/dist/workspace-mapping-service.d.ts +8 -0
  57. package/dist/workspace-mapping-service.js +35 -7
  58. package/docs/README-ja.md +2 -0
  59. package/docs/tool-reference.md +55 -11
  60. package/package.json +1 -1
@@ -15,6 +15,40 @@ async function safeEmit(emitter, stage, payload) {
15
15
  // swallow telemetry failure
16
16
  }
17
17
  }
18
+ const DISCOVER_KIND_LABELS = [
19
+ ["mixins", "mixin configs"],
20
+ ["access-wideners", "access wideners"],
21
+ ["access-transformers", "access transformers"]
22
+ ];
23
+ function joinWithOr(items) {
24
+ if (items.length <= 2) {
25
+ return items.join(" or ");
26
+ }
27
+ return `${items.slice(0, -1).join(", ")}, or ${items[items.length - 1]}`;
28
+ }
29
+ // A run that discovered no files keeps status "ok" (nothing failed), so the
30
+ // headline and a warning must say that nothing was checked; otherwise the
31
+ // result reads as a pass. Only the kinds subject.discover searched are named.
32
+ function describeNothingToValidate(discover, projectPath) {
33
+ const searched = DISCOVER_KIND_LABELS
34
+ .filter(([kind]) => discover.includes(kind))
35
+ .map(([, label]) => label);
36
+ const notOk = "so status \"ok\" does not mean any file passed validation.";
37
+ const accessTransformerHint = discover.includes("access-transformers")
38
+ ? ""
39
+ : " Access Transformer files are searched only when subject.discover includes \"access-transformers\".";
40
+ if (searched.length === 0) {
41
+ return {
42
+ headline: "Nothing to validate: subject.discover selected no file kinds to search.",
43
+ warning: `Nothing was validated: subject.discover selected no file kinds to search, ${notOk}${accessTransformerHint}`
44
+ };
45
+ }
46
+ const list = joinWithOr(searched);
47
+ return {
48
+ headline: `Nothing to validate: no ${list} were found.`,
49
+ warning: `Nothing was validated: no ${list} were found under ${projectPath}, ${notOk}${accessTransformerHint}`
50
+ };
51
+ }
18
52
  export async function handleProjectSummary(deps, input, detail, include, options = {}) {
19
53
  // Forwarded emitter for nested validators and probes; same swallow contract
20
54
  // as safeEmit so a rejecting raw emitter cannot leak into their outcomes.
@@ -27,7 +61,9 @@ export async function handleProjectSummary(deps, input, detail, include, options
27
61
  message: "task=project-summary requires subject.kind=workspace."
28
62
  });
29
63
  }
30
- if (!input.version && !input.preferProjectVersion) {
64
+ // An omitted version is inferred from the project below; only an explicit
65
+ // preferProjectVersion=false opts out of that inference.
66
+ if (!input.version && input.preferProjectVersion === false) {
31
67
  const baseResult = buildEntryToolResult({
32
68
  task: "project-summary",
33
69
  detail,
@@ -52,7 +88,7 @@ export async function handleProjectSummary(deps, input, detail, include, options
52
88
  }
53
89
  ],
54
90
  notes: [
55
- "Pass version explicitly, or retry with preferProjectVersion=true when gradle.properties declares the Minecraft version. The suggested retry sets preferProjectVersion=true for you."
91
+ "Version inference was suppressed by preferProjectVersion=false. Pass version explicitly, or retry without preferProjectVersion=false to infer it from gradle.properties. The suggested retry sets preferProjectVersion=true for you."
56
92
  ]
57
93
  },
58
94
  blocks: {
@@ -79,10 +115,21 @@ export async function handleProjectSummary(deps, input, detail, include, options
79
115
  projectPath,
80
116
  discover
81
117
  });
82
- const detectedProjectVersion = input.preferProjectVersion
118
+ // preferProjectVersion=true overrides an explicit version; an omitted version is
119
+ // inferred (the explicit-false opt-out returned above).
120
+ const detectedProjectVersion = input.preferProjectVersion === true || !input.version
83
121
  ? await deps.detectProjectMinecraftVersion?.(projectPath)
84
122
  : undefined;
85
123
  const resolvedVersion = detectedProjectVersion ?? input.version;
124
+ const versionInferred = !input.version && Boolean(detectedProjectVersion);
125
+ // An inferred version is a project-version resolution: sub-validators and the
126
+ // artifact probe see exactly what an explicit preferProjectVersion=true run sees.
127
+ const effectivePreferProjectVersion = versionInferred ? true : input.preferProjectVersion;
128
+ const versionInferenceWarnings = versionInferred
129
+ ? [
130
+ `version was inferred from the workspace: ${detectedProjectVersion} (source: projectPath:gradle.properties (${projectPath})).`
131
+ ]
132
+ : [];
86
133
  const [mixinConfigs, accessWideners, accessTransformers] = await Promise.all([
87
134
  discover.includes("mixins")
88
135
  ? deps.discoverMixins(projectPath, input.configPaths)
@@ -111,17 +158,20 @@ export async function handleProjectSummary(deps, input, detail, include, options
111
158
  sourcePriority: input.sourcePriority,
112
159
  scope: input.scope
113
160
  }),
161
+ // Retrying without a version would infer, fail, and block again, so the
162
+ // recovery asks for an explicit version instead.
114
163
  nextActions: [
115
164
  {
116
165
  tool: "validate-project",
117
166
  params: {
118
167
  task: "project-summary",
119
- subject: input.subject
168
+ subject: input.subject,
169
+ version: "<minecraft-version>"
120
170
  }
121
171
  }
122
172
  ],
123
173
  notes: [
124
- "Pass version explicitly, or make sure gradle.properties declares the Minecraft version before using preferProjectVersion=true."
174
+ "The Minecraft version could not be inferred from gradle.properties (minecraft_version, mc_version, or minecraftVersion). Retry with version set explicitly: replace \"<minecraft-version>\" in the suggested call with the project's Minecraft version, or declare it in gradle.properties."
125
175
  ]
126
176
  },
127
177
  blocks: {
@@ -150,14 +200,16 @@ export async function handleProjectSummary(deps, input, detail, include, options
150
200
  ]
151
201
  };
152
202
  }
203
+ // Reached only when discovery found nothing (the branch above handles found files).
153
204
  if (!resolvedVersion) {
205
+ const nothingToValidate = describeNothingToValidate(discover, projectPath);
154
206
  const baseResult = buildEntryToolResult({
155
207
  task: "project-summary",
156
208
  detail,
157
209
  include,
158
210
  summary: {
159
211
  status: "ok",
160
- headline: `Validated ${mixinConfigs.length} mixin config(s), ${accessWideners.length} access widener(s), and ${accessTransformers.length} access transformer(s).`,
212
+ headline: nothingToValidate.headline,
161
213
  subject: createSummarySubject({
162
214
  task: "project-summary",
163
215
  kind: input.subject.kind,
@@ -187,11 +239,11 @@ export async function handleProjectSummary(deps, input, detail, include, options
187
239
  return {
188
240
  ...baseResult,
189
241
  ...(tasks ? { tasks } : {}),
190
- warnings: []
242
+ warnings: [nothingToValidate.warning]
191
243
  };
192
244
  }
193
245
  const validationVersion = resolvedVersion;
194
- const warnings = [];
246
+ const warnings = [...versionInferenceWarnings];
195
247
  const mixinDurationStart = Date.now();
196
248
  let validMixins = 0;
197
249
  let partialMixins = 0;
@@ -288,7 +340,7 @@ export async function handleProjectSummary(deps, input, detail, include, options
288
340
  projectPath,
289
341
  gradleUserHome,
290
342
  scope: input.scope,
291
- preferProjectVersion: input.preferProjectVersion
343
+ preferProjectVersion: effectivePreferProjectVersion
292
344
  });
293
345
  if (output.valid) {
294
346
  validAw += 1;
@@ -337,7 +389,7 @@ export async function handleProjectSummary(deps, input, detail, include, options
337
389
  projectPath,
338
390
  gradleUserHome,
339
391
  scope: input.scope,
340
- preferProjectVersion: input.preferProjectVersion
392
+ preferProjectVersion: effectivePreferProjectVersion
341
393
  });
342
394
  if (output.valid) {
343
395
  validAt += 1;
@@ -361,13 +413,20 @@ export async function handleProjectSummary(deps, input, detail, include, options
361
413
  const invalidCount = invalidMixins + invalidAw + invalidAt;
362
414
  const partialCount = partialMixins;
363
415
  const status = invalidCount > 0 ? "invalid" : partialCount > 0 ? "partial" : "ok";
416
+ const nothingToValidate = mixinConfigs.length === 0 && accessWideners.length === 0 && accessTransformers.length === 0
417
+ ? describeNothingToValidate(discover, projectPath)
418
+ : undefined;
419
+ if (nothingToValidate) {
420
+ warnings.push(nothingToValidate.warning);
421
+ }
364
422
  const baseResult = buildEntryToolResult({
365
423
  task: "project-summary",
366
424
  detail,
367
425
  include,
368
426
  summary: {
369
427
  status,
370
- headline: `Validated ${mixinConfigs.length} mixin config(s), ${accessWideners.length} access widener(s), and ${accessTransformers.length} access transformer(s).`,
428
+ headline: nothingToValidate?.headline ??
429
+ `Validated ${mixinConfigs.length} mixin config(s), ${accessWideners.length} access widener(s), and ${accessTransformers.length} access transformer(s).`,
371
430
  subject: createSummarySubject({
372
431
  task: "project-summary",
373
432
  kind: input.subject.kind,
@@ -416,7 +475,7 @@ export async function handleProjectSummary(deps, input, detail, include, options
416
475
  sourcePriority: input.sourcePriority,
417
476
  gradleUserHome,
418
477
  scope: input.scope,
419
- preferProjectVersion: input.preferProjectVersion,
478
+ preferProjectVersion: effectivePreferProjectVersion,
420
479
  mixinDiscoveryCount: mixinConfigs.length,
421
480
  mixinCaughtErrors,
422
481
  mixinCounts: { ok: validMixins, partial: partialMixins, invalid: invalidMixins },
@@ -124,9 +124,9 @@ export declare const validateProjectShape: {
124
124
  preferProjectVersion: z.ZodOptional<z.ZodBoolean>;
125
125
  preferProjectMapping: z.ZodDefault<z.ZodBoolean>;
126
126
  detail: z.ZodOptional<z.ZodEnum<{
127
+ full: "full";
127
128
  summary: "summary";
128
129
  standard: "standard";
129
- full: "full";
130
130
  }>>;
131
131
  include: z.ZodOptional<z.ZodArray<z.ZodEnum<{
132
132
  [x: string]: string;
@@ -236,9 +236,9 @@ export declare const validateProjectSchema: z.ZodObject<{
236
236
  preferProjectVersion: z.ZodOptional<z.ZodBoolean>;
237
237
  preferProjectMapping: z.ZodDefault<z.ZodBoolean>;
238
238
  detail: z.ZodOptional<z.ZodEnum<{
239
+ full: "full";
239
240
  summary: "summary";
240
241
  standard: "standard";
241
- full: "full";
242
242
  }>>;
243
243
  include: z.ZodOptional<z.ZodArray<z.ZodEnum<{
244
244
  [x: string]: string;
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { prepareToolInput } from "./tool-input.js";
11
11
  import { DETAIL_ENABLED_TOOL_NAMES, DEFAULT_DETAIL_BY_TOOL, projectByDetail } from "./response-utils.js";
12
12
  import { loadConfig } from "./config.js";
13
13
  import { createError, ERROR_CODES, isAppError } from "./errors.js";
14
+ import { convertRuntimeSqliteCorruption } from "./storage/db.js";
14
15
  import { log } from "./logger.js";
15
16
  import { applyNbtJsonPatch, nbtBase64ToTypedJson, typedJsonToNbtBase64 } from "./nbt/pipeline.js";
16
17
  import { analyzeModJar } from "./mod-analyzer.js";
@@ -136,7 +137,8 @@ const analyzeSymbolService = new AnalyzeSymbolService({
136
137
  const compareMinecraftService = new CompareMinecraftService({
137
138
  compareVersions: (input) => sourceService.compareVersions(input),
138
139
  diffClassSignatures: (input) => sourceService.diffClassSignatures(input),
139
- getRegistryData: (input) => sourceService.getRegistryData(input)
140
+ getRegistryData: (input) => sourceService.getRegistryData(input),
141
+ getVersionLibraries: (input) => sourceService.getVersionLibraries(input.version)
140
142
  });
141
143
  const analyzeModService = new AnalyzeModService({
142
144
  analyzeModJar: (jarPath, options) => analyzeModJar(jarPath, options),
@@ -428,38 +430,58 @@ async function runTool(tool, rawInput, schema, action) {
428
430
  });
429
431
  }
430
432
  catch (caughtError) {
431
- const problem = mapErrorToProblem(caughtError, requestId, {
433
+ // A raw SQLite corruption error surfaced mid-call (as opposed to at DB
434
+ // open, which is already a typed AppError by the time it gets here) means
435
+ // quick_check passed for this file but a live query still hit corruption.
436
+ // Escalate the next open to a full integrity_check and report a typed,
437
+ // restart-guiding ERR_DB_FAILURE instead of leaking ERR_INTERNAL.
438
+ const reportedError = convertRuntimeSqliteCorruption(caughtError, config.sqlitePath);
439
+ if (reportedError !== caughtError) {
440
+ // The public envelope only ever gets the generic ERR_DB_FAILURE message
441
+ // and nextAction (see convertRuntimeSqliteCorruption) - the original
442
+ // SQLite diagnostic is server-side-only, logged here with the request
443
+ // context convertRuntimeSqliteCorruption itself does not have access to.
444
+ const rawSqliteError = caughtError;
445
+ log("error", "tool.call.sqlite_runtime_corruption", {
446
+ requestId,
447
+ tool,
448
+ message: typeof rawSqliteError?.message === "string" ? rawSqliteError.message : String(caughtError),
449
+ code: rawSqliteError?.code,
450
+ errcode: rawSqliteError?.errcode
451
+ });
452
+ }
453
+ const problem = mapErrorToProblem(reportedError, requestId, {
432
454
  tool,
433
455
  normalizedInput
434
456
  });
435
- if (isAppError(caughtError)) {
436
- const isSevere = caughtError.code === ERROR_CODES.DB_FAILURE ||
437
- caughtError.code === ERROR_CODES.REPO_FETCH_FAILED ||
438
- caughtError.code === ERROR_CODES.REGISTRY_GENERATION_FAILED ||
439
- caughtError.code === ERROR_CODES.JAVA_UNAVAILABLE ||
440
- caughtError.code.startsWith("ERR_DECOMPILER");
457
+ if (isAppError(reportedError)) {
458
+ const isSevere = reportedError.code === ERROR_CODES.DB_FAILURE ||
459
+ reportedError.code === ERROR_CODES.REPO_FETCH_FAILED ||
460
+ reportedError.code === ERROR_CODES.REGISTRY_GENERATION_FAILED ||
461
+ reportedError.code === ERROR_CODES.JAVA_UNAVAILABLE ||
462
+ reportedError.code.startsWith("ERR_DECOMPILER");
441
463
  if (isSevere) {
442
464
  log("error", "tool.call.failed", {
443
465
  requestId,
444
466
  tool,
445
- code: caughtError.code,
446
- message: caughtError.message
467
+ code: reportedError.code,
468
+ message: reportedError.message
447
469
  });
448
470
  }
449
471
  else {
450
472
  log("warn", "tool.call.warning", {
451
473
  requestId,
452
474
  tool,
453
- code: caughtError.code,
454
- message: caughtError.message
475
+ code: reportedError.code,
476
+ message: reportedError.message
455
477
  });
456
478
  }
457
479
  }
458
- else if (!(caughtError instanceof ZodError)) {
480
+ else if (!(reportedError instanceof ZodError)) {
459
481
  log("error", "tool.call.unhandled", {
460
482
  requestId,
461
483
  tool,
462
- reason: caughtError instanceof Error ? caughtError.message : String(caughtError)
484
+ reason: reportedError instanceof Error ? reportedError.message : String(reportedError)
463
485
  });
464
486
  }
465
487
  const errorDurationMs = Date.now() - startedAt;
@@ -469,7 +491,7 @@ async function runTool(tool, rawInput, schema, action) {
469
491
  tool,
470
492
  durationMs: errorDurationMs
471
493
  };
472
- applyErrorMetaExtensions(errorMeta, caughtError);
494
+ applyErrorMetaExtensions(errorMeta, reportedError);
473
495
  return objectResult({
474
496
  error: problem,
475
497
  meta: errorMeta
@@ -78,6 +78,15 @@ export declare class JsonRpcFrameReader {
78
78
  private awaitedBodyStart;
79
79
  private idleTimer;
80
80
  private fatal;
81
+ /**
82
+ * Set when an oversized, not-yet-terminated line/header-less run was just
83
+ * discarded with no newline in hand. The bytes that eventually complete
84
+ * that same logical line are not a delimiter the reader can trust as the
85
+ * start of a fresh frame, so every byte up to and including the next 0x0a —
86
+ * however many chunks it takes to arrive — is swallowed unread before
87
+ * normal parsing resumes. See `rejectOversizedIncompleteInput`.
88
+ */
89
+ private discardingOversizedLine;
81
90
  /**
82
91
  * @param options.maxFrameBytes Largest accepted frame; defaults to
83
92
  * {@link loadMaxFrameBytes}.
@@ -128,6 +137,17 @@ export declare class JsonRpcFrameReader {
128
137
  * a caller that does not still gets a live pair rather than a stale one.
129
138
  */
130
139
  private armIdleTimer;
140
+ /**
141
+ * Whether the buffer, despite `mode` still sticking at "content-length"
142
+ * from an earlier frame, actually opens a line-mode frame — the same probe
143
+ * `readContentLengthMessage` uses to detect the mid-stream switch back to
144
+ * line framing (a JSON object/array opener can never begin a Content-Length
145
+ * header block). Used to keep the header-size ceiling scoped to buffers
146
+ * still being accumulated as a header block, so it never judges a line
147
+ * frame's bytes as an oversized header just because the switch hasn't been
148
+ * recognized yet.
149
+ */
150
+ private looksLikeLineFrame;
131
151
  private canCompleteFrame;
132
152
  private rejectOversizedIncompleteInput;
133
153
  /**
@@ -40,8 +40,10 @@ const clearIdleTimerHandle = (handle) => {
40
40
  *
41
41
  * Consequence worth stating, because it is load-bearing for the caller: an
42
42
  * EXTRA empty line after the terminator is body, not header. The body window
43
- * then opens on that empty line and is shifted by the two or three bytes the
44
- * peer did not count, so it no longer covers the same span as the JSON value.
43
+ * then opens on that empty line, shifted by however many bytes that line's own
44
+ * terminator took one for a bare LF, two for CRLF bytes the peer's
45
+ * declared length did not count, so the window no longer covers the same span
46
+ * as the JSON value.
45
47
  * All four extra-blank-line shapes behave alike here, which is the point: the
46
48
  * reading does not depend on which terminator style the peer chose.
47
49
  *
@@ -171,6 +173,15 @@ export class JsonRpcFrameReader {
171
173
  awaitedBodyStart = -1;
172
174
  idleTimer;
173
175
  fatal = false;
176
+ /**
177
+ * Set when an oversized, not-yet-terminated line/header-less run was just
178
+ * discarded with no newline in hand. The bytes that eventually complete
179
+ * that same logical line are not a delimiter the reader can trust as the
180
+ * start of a fresh frame, so every byte up to and including the next 0x0a —
181
+ * however many chunks it takes to arrive — is swallowed unread before
182
+ * normal parsing resumes. See `rejectOversizedIncompleteInput`.
183
+ */
184
+ discardingOversizedLine = false;
174
185
  /**
175
186
  * @param options.maxFrameBytes Largest accepted frame; defaults to
176
187
  * {@link loadMaxFrameBytes}.
@@ -206,6 +217,7 @@ export class JsonRpcFrameReader {
206
217
  this.awaitedFrameEnd = -1;
207
218
  this.awaitedBodyStart = -1;
208
219
  this.fatal = false;
220
+ this.discardingOversizedLine = false;
209
221
  }
210
222
  clear() {
211
223
  this.clearIdleTimer();
@@ -216,6 +228,7 @@ export class JsonRpcFrameReader {
216
228
  this.awaitedFrameEnd = -1;
217
229
  this.awaitedBodyStart = -1;
218
230
  this.fatal = false;
231
+ this.discardingOversizedLine = false;
219
232
  }
220
233
  processChunk(chunk, handlers) {
221
234
  if (chunk.length === 0 || this.fatal) {
@@ -242,6 +255,24 @@ export class JsonRpcFrameReader {
242
255
  this.pendingBytes = 0;
243
256
  while (true) {
244
257
  try {
258
+ if (this.discardingOversizedLine) {
259
+ // Swallow bytes up to and including the next newline WITHOUT
260
+ // interpreting them as a frame — they are the tail of the line just
261
+ // rejected as oversized, not a fresh start, even if they happen to
262
+ // look like a well-formed message on their own. Only once that
263
+ // terminator is found does this resynchronize on the byte position
264
+ // the peer itself delimited.
265
+ const newlineIndex = this.buffer.indexOf(0x0a);
266
+ if (newlineIndex === -1) {
267
+ // The discarded bytes carry no information, so there is nothing
268
+ // to hold onto while waiting for the terminator.
269
+ this.buffer = Buffer.alloc(0);
270
+ return;
271
+ }
272
+ this.buffer = this.buffer.subarray(newlineIndex + 1);
273
+ this.discardingOversizedLine = false;
274
+ continue;
275
+ }
245
276
  this.rejectOversizedIncompleteInput();
246
277
  if (this.mode === "unknown") {
247
278
  const detected = this.detectMode();
@@ -297,6 +328,7 @@ export class JsonRpcFrameReader {
297
328
  this.buffer = Buffer.alloc(0);
298
329
  this.pendingChunks = [];
299
330
  this.pendingBytes = 0;
331
+ this.discardingOversizedLine = false;
300
332
  handlers.onError(error);
301
333
  return;
302
334
  }
@@ -361,6 +393,28 @@ export class JsonRpcFrameReader {
361
393
  handle = this.scheduleTimer(expire, this.incompleteFrameIdleMs);
362
394
  this.idleTimer = handle;
363
395
  }
396
+ /**
397
+ * Whether the buffer, despite `mode` still sticking at "content-length"
398
+ * from an earlier frame, actually opens a line-mode frame — the same probe
399
+ * `readContentLengthMessage` uses to detect the mid-stream switch back to
400
+ * line framing (a JSON object/array opener can never begin a Content-Length
401
+ * header block). Used to keep the header-size ceiling scoped to buffers
402
+ * still being accumulated as a header block, so it never judges a line
403
+ * frame's bytes as an oversized header just because the switch hasn't been
404
+ * recognized yet.
405
+ */
406
+ looksLikeLineFrame() {
407
+ let probeIndex = 0;
408
+ while (probeIndex < this.buffer.length &&
409
+ (this.buffer[probeIndex] === 0x20 ||
410
+ this.buffer[probeIndex] === 0x09 ||
411
+ this.buffer[probeIndex] === 0x0d ||
412
+ this.buffer[probeIndex] === 0x0a)) {
413
+ probeIndex += 1;
414
+ }
415
+ return (probeIndex < this.buffer.length &&
416
+ (this.buffer[probeIndex] === 0x7b /* '{' */ || this.buffer[probeIndex] === 0x5b /* '[' */));
417
+ }
364
418
  canCompleteFrame(chunk) {
365
419
  const bufferedBytes = this.buffer.length + this.pendingBytes;
366
420
  if (this.mode === "content-length" && this.awaitedFrameEnd >= 0) {
@@ -373,8 +427,17 @@ export class JsonRpcFrameReader {
373
427
  return chunk.includes(0x0a) || bufferedBytes > this.maxFrameBytes;
374
428
  }
375
429
  rejectOversizedIncompleteInput() {
376
- const headerBoundary = this.mode === "content-length" ? findHeaderBoundary(this.buffer) : undefined;
377
- if (this.mode === "content-length" &&
430
+ // Sticky "content-length" mode only means a header block is being
431
+ // accumulated when the buffer doesn't already look like a line frame; a
432
+ // JSON object/array opener here is the same mid-stream switch
433
+ // `readContentLengthMessage` recognizes, just not yet reached. The header
434
+ // ceiling below must be scoped to actual header accumulation, or a large
435
+ // line-mode frame arriving right after a Content-Length frame gets judged
436
+ // as an oversized header before the switch is detected.
437
+ const isLineFrameAfterContentLength = this.mode === "content-length" && this.looksLikeLineFrame();
438
+ const inHeaderAccumulation = this.mode === "content-length" && !isLineFrameAfterContentLength;
439
+ const headerBoundary = inHeaderAccumulation ? findHeaderBoundary(this.buffer) : undefined;
440
+ if (inHeaderAccumulation &&
378
441
  !headerBoundary &&
379
442
  this.buffer.length > MAX_CONTENT_LENGTH_HEADER_BYTES) {
380
443
  // No header terminator anywhere in an over-limit header block: there is
@@ -387,15 +450,25 @@ export class JsonRpcFrameReader {
387
450
  if (this.buffer.length <= this.maxFrameBytes) {
388
451
  return;
389
452
  }
390
- if (this.mode === "content-length" && headerBoundary) {
453
+ if (inHeaderAccumulation && headerBoundary) {
391
454
  return;
392
455
  }
393
- if (this.mode !== "content-length" && this.buffer.includes(0x0a)) {
456
+ if (!inHeaderAccumulation && this.buffer.includes(0x0a)) {
394
457
  return;
395
458
  }
396
459
  const observedBytes = this.buffer.length;
397
- const description = this.mode === "line" ? "Line-delimited JSON-RPC frame" : "Headerless JSON-RPC input";
460
+ const description = this.mode === "line" || isLineFrameAfterContentLength
461
+ ? "Line-delimited JSON-RPC frame"
462
+ : "Headerless JSON-RPC input";
398
463
  this.buffer = Buffer.alloc(0);
464
+ if (!inHeaderAccumulation) {
465
+ // The oversized run has no newline anywhere in it yet (the check above
466
+ // would otherwise have returned): remember to swallow bytes through the
467
+ // eventual terminator — wherever it arrives — before resuming normal
468
+ // parsing, so the discarded line's own tail is never re-read as a fresh
469
+ // frame (see `discardingOversizedLine` in `drainChunk`).
470
+ this.discardingOversizedLine = true;
471
+ }
399
472
  throw new Error(`${description} is ${observedBytes} bytes, exceeding the configured frame limit of ` +
400
473
  `${this.maxFrameBytes} bytes.`);
401
474
  }
@@ -1,2 +1,11 @@
1
+ import type { DirectionIndex, PairKey } from "../internal-types.js";
1
2
  import type { MappingLoaderDeps, MappingLoaderResult } from "./types.js";
3
+ /**
4
+ * `maxEntryBytes` reuses the same ceiling as nested-jar extraction
5
+ * ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
6
+ * download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
7
+ * decompressed in full before parsing, so an entry with a small compressed
8
+ * size and a huge inflated size (zip-bomb style) must still be bounded here.
9
+ */
10
+ export declare function parseTinyFromJar(jarPath: string, maxEntryBytes?: number): Promise<Map<PairKey, DirectionIndex>>;
2
11
  export declare function loadTinyPairsFromMaven(deps: MappingLoaderDeps, version: string): Promise<MappingLoaderResult>;
@@ -1,4 +1,5 @@
1
1
  import { defaultDownloadPath, downloadToCache } from "../../repo-downloader.js";
2
+ import { loadMaxNestedJarEntryBytes } from "../../source/nested-jars.js";
2
3
  import { collectMatchedJarEntriesAsUtf8 } from "../../source-jar-reader.js";
3
4
  import { mergeDirectionIndexes } from "../parsers/symbol-records.js";
4
5
  import { parseTinyMappingsInto } from "../parsers/tiny.js";
@@ -27,8 +28,15 @@ async function fetchYarnCoordinates(fetchFn, repoBase, version) {
27
28
  return [version];
28
29
  }
29
30
  }
30
- async function parseTinyFromJar(jarPath) {
31
- const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true })).sort((left, right) => left.filePath.localeCompare(right.filePath));
31
+ /**
32
+ * `maxEntryBytes` reuses the same ceiling as nested-jar extraction
33
+ * ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
34
+ * download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
35
+ * decompressed in full before parsing, so an entry with a small compressed
36
+ * size and a huge inflated size (zip-bomb style) must still be bounded here.
37
+ */
38
+ export async function parseTinyFromJar(jarPath, maxEntryBytes = loadMaxNestedJarEntryBytes()) {
39
+ const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true, maxBytes: maxEntryBytes })).sort((left, right) => left.filePath.localeCompare(right.filePath));
32
40
  // Parsed straight into the shared accumulator: a parse-then-merge loop would
33
41
  // hold each entry's full index alongside the accumulated one. `ensurePairIndex`
34
42
  // + `addLookupEntries` union into what is already there, matching what
@@ -771,13 +771,24 @@ async function cachedBytesResult(url, destinationPath, sidecar, cacheStatus) {
771
771
  * a zip reader opens it - so it must satisfy neither an immutable hit nor a
772
772
  * stale-if-error fallback. Treating it as absent lets the next transfer replace
773
773
  * it instead of pinning it forever.
774
+ *
775
+ * That equivalence stops at "missing", the same line {@link describeFileIfPresent}
776
+ * draws: only {@link isMissingFileError} answers a cache miss. A present-but-
777
+ * unreadable entry (EACCES from a locked-down cache directory, EISDIR, EIO)
778
+ * propagates instead of collapsing to 0, because this is the first stat this
779
+ * module makes on the path - collapsing it here would let `resolveCachedDownload`
780
+ * read "no cached bytes" and fall through to a live transfer without ever
781
+ * reaching the read path that already reports this failure correctly.
774
782
  */
775
783
  function cachedByteCount(filePath) {
776
784
  try {
777
785
  return statSync(filePath).size;
778
786
  }
779
- catch {
780
- return 0;
787
+ catch (error) {
788
+ if (isMissingFileError(error)) {
789
+ return 0;
790
+ }
791
+ throw error;
781
792
  }
782
793
  }
783
794
  /**
@@ -1,3 +1,4 @@
1
+ import { type JavaSourceScan } from "../source-jar-reader.js";
1
2
  import type { SourceService } from "../source-service.js";
2
3
  import type { ArtifactContentsSummary, ProbeMinecraftArtifactInput, ProbeMinecraftArtifactOutput, ResolveArtifactInput, ResolveArtifactOutput } from "../source-service.js";
3
4
  import type { AccessTransformerNamespace, ArtifactProvenance, ArtifactScope, ArtifactTargetKind, ResolvedSourceArtifact, RuntimeLoader, RuntimeValidationProvenance, SourceMapping } from "../types.js";
@@ -143,6 +144,19 @@ export declare function hasLoaderRuntimeVersionToken(path: string, mcVersion: st
143
144
  * of the path is the version the jar was built for.
144
145
  */
145
146
  export declare function inferRuntimeJarMinecraftVersion(path: string): string | undefined;
147
+ /**
148
+ * The Minecraft version a `kind: "jar"` target PROVES it is the unobfuscated
149
+ * runtime of, or undefined. The file path is never evidence: Loom and other
150
+ * tools lay jars out in ways that path heuristics have misread before.
151
+ *
152
+ * All three must hold: the walk met no `.java` entry, the jar ships
153
+ * `net/minecraft/SharedConstants.class`, and its root `version.json` parses with a
154
+ * string `id` that `isUnobfuscatedVersion` accepts. The id is put to that test
155
+ * only once the other two hold, so it is the id a Minecraft `version.json`
156
+ * declares, never a library's own release number. A Loom-mapped 1.x jar carries
157
+ * both signals too; its `1.x` id is what keeps it out. The path is never read.
158
+ */
159
+ export declare function provenUnobfuscatedRuntimeJarVersion(scan: JavaSourceScan | undefined): string | undefined;
146
160
  /**
147
161
  * Loader a runtime jar belongs to, read from the tool-written part of its path.
148
162
  *