@adhisang/minecraft-modding-mcp 7.0.0 → 7.1.1

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 (95) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +4 -3
  3. package/dist/access-transformer-parser.d.ts +8 -0
  4. package/dist/access-transformer-parser.js +8 -1
  5. package/dist/access-widener-parser.d.ts +17 -0
  6. package/dist/access-widener-parser.js +12 -1
  7. package/dist/cache-registry.js +19 -6
  8. package/dist/entry-tools/analyze-mod-service.d.ts +2 -2
  9. package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
  10. package/dist/entry-tools/batch-class-members-service.d.ts +3 -2
  11. package/dist/entry-tools/batch-class-members-service.js +20 -6
  12. package/dist/entry-tools/batch-class-source-service.d.ts +3 -2
  13. package/dist/entry-tools/batch-class-source-service.js +10 -0
  14. package/dist/entry-tools/compare-minecraft-service.d.ts +27 -4
  15. package/dist/entry-tools/compare-minecraft-service.js +65 -4
  16. package/dist/entry-tools/entry-tool-schema.d.ts +2 -2
  17. package/dist/entry-tools/inspect-minecraft/handlers/versions.js +15 -9
  18. package/dist/entry-tools/inspect-minecraft-service.d.ts +2 -2
  19. package/dist/entry-tools/manage-cache-service.d.ts +2 -2
  20. package/dist/entry-tools/manage-cache-service.js +10 -14
  21. package/dist/entry-tools/validate-project/cases/access-transformer.js +22 -3
  22. package/dist/entry-tools/validate-project/cases/access-widener.js +31 -4
  23. package/dist/entry-tools/validate-project/cases/mixin.js +11 -3
  24. package/dist/entry-tools/validate-project/cases/project-summary.js +94 -16
  25. package/dist/entry-tools/validate-project-service.d.ts +2 -2
  26. package/dist/entry-tools/verify-mixin-target-service.js +19 -8
  27. package/dist/index.js +38 -15
  28. package/dist/java-process.d.ts +1 -0
  29. package/dist/java-process.js +14 -0
  30. package/dist/mapping/lookup.js +16 -1
  31. package/dist/mapping-service.d.ts +14 -0
  32. package/dist/mapping-service.js +35 -15
  33. package/dist/minecraft-explorer-service.js +70 -8
  34. package/dist/mixin/access-validators.js +38 -2
  35. package/dist/mixin/annotation-validators.js +137 -43
  36. package/dist/mixin/parsed-validator.js +21 -7
  37. package/dist/mixin-parser.d.ts +52 -0
  38. package/dist/mixin-parser.js +709 -130
  39. package/dist/mod-decompile-service.js +11 -1
  40. package/dist/mod-remap-service.js +6 -6
  41. package/dist/nbt/java-nbt-codec.js +7 -1
  42. package/dist/source/access-validate.js +10 -0
  43. package/dist/source/artifact-resolver.d.ts +27 -3
  44. package/dist/source/artifact-resolver.js +235 -30
  45. package/dist/source/class-source/members-builder.d.ts +7 -0
  46. package/dist/source/class-source/members-builder.js +4 -1
  47. package/dist/source/class-source.d.ts +9 -2
  48. package/dist/source/class-source.js +186 -27
  49. package/dist/source/indexer.js +69 -1
  50. package/dist/source/lifecycle/mapping-helpers.d.ts +20 -1
  51. package/dist/source/lifecycle/mapping-helpers.js +29 -3
  52. package/dist/source/lifecycle/runtime-check.d.ts +25 -0
  53. package/dist/source/lifecycle/runtime-check.js +68 -39
  54. package/dist/source/nested-jars.d.ts +15 -1
  55. package/dist/source/nested-jars.js +14 -5
  56. package/dist/source/search.d.ts +10 -2
  57. package/dist/source/search.js +60 -13
  58. package/dist/source/symbol-resolver.js +88 -0
  59. package/dist/source/validate-mixin/pipeline/mapping-health.js +20 -1
  60. package/dist/source/validate-mixin/pipeline/target-lookup.js +16 -7
  61. package/dist/source/validate-mixin.d.ts +5 -0
  62. package/dist/source/validate-mixin.js +136 -21
  63. package/dist/source/workspace-target.js +75 -7
  64. package/dist/source-jar-reader.d.ts +48 -1
  65. package/dist/source-jar-reader.js +93 -3
  66. package/dist/source-resolver.d.ts +7 -0
  67. package/dist/source-resolver.js +22 -14
  68. package/dist/source-service.d.ts +5 -0
  69. package/dist/source-service.js +7 -0
  70. package/dist/stdio-supervisor.d.ts +35 -1
  71. package/dist/stdio-supervisor.js +77 -2
  72. package/dist/storage/db.d.ts +62 -2
  73. package/dist/storage/db.js +181 -20
  74. package/dist/storage/files-repo.d.ts +7 -0
  75. package/dist/storage/files-repo.js +17 -4
  76. package/dist/storage/sqlite.d.ts +31 -1
  77. package/dist/storage/sqlite.js +125 -16
  78. package/dist/tool-contract-manifest.js +2 -2
  79. package/dist/tool-execution-gate.js +2 -1
  80. package/dist/tool-guidance.js +4 -1
  81. package/dist/tool-schemas.d.ts +64 -52
  82. package/dist/tool-schemas.js +9 -7
  83. package/dist/types.d.ts +9 -0
  84. package/dist/v1-parity-schemas.js +36 -2
  85. package/dist/version-diff-service.d.ts +23 -0
  86. package/dist/version-diff-service.js +101 -0
  87. package/dist/version-service.d.ts +14 -0
  88. package/dist/version-service.js +52 -3
  89. package/dist/workspace-context-cache.d.ts +25 -0
  90. package/dist/workspace-context-cache.js +52 -2
  91. package/dist/workspace-mapping-service.d.ts +8 -0
  92. package/dist/workspace-mapping-service.js +151 -21
  93. package/docs/README-ja.md +3 -1
  94. package/docs/tool-reference.md +69 -22
  95. package/package.json +1 -1
@@ -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;
@@ -51,14 +51,16 @@ function visibilityFromFlags(flags) {
51
51
  function buildExampleSnippet(annotation, member, match, mixinMemberName) {
52
52
  const accessFlags = match?.accessFlags ?? [];
53
53
  const isFinal = accessFlags.includes("final");
54
+ const isStatic = accessFlags.includes("static");
54
55
  const targetName = member.name;
55
56
  if (annotation === "@Inject-only") {
56
- return `// "${targetName}" is already visible to the mixin (target access: ${accessFlags.filter((f) => f === "public" || f === "protected" || f === "private" || f === "package-private")[0] ?? "unknown"}); use @Inject directly without @Shadow.`;
57
+ return `// "${targetName}" is already visible to the mixin (target access: ${accessFlags.filter((f) => f === "public" || f === "protected" || f === "private" || f === "package-private")[0] ?? "unknown"}); no @Invoker needed — use @Inject to hook it, or @Shadow to call it from mixin code.`;
57
58
  }
58
59
  if (annotation === "@Shadow") {
59
60
  if (member.kind === "field") {
60
61
  const finalTag = isFinal ? "@Shadow @Final\n" : "@Shadow\n";
61
- return `${finalTag}private <type> ${targetName};`;
62
+ const modifiers = isStatic ? "private static" : "private";
63
+ return `${finalTag}${modifiers} <type> ${targetName};`;
62
64
  }
63
65
  return `@Shadow\nprivate <returnType> ${targetName}(<params>);`;
64
66
  }
@@ -76,9 +78,18 @@ function buildExampleSnippet(annotation, member, match, mixinMemberName) {
76
78
  function inferAnnotation(member, match, mixinMemberName) {
77
79
  const visibility = match ? deriveVisibility(match.accessFlags) : "private";
78
80
  if (visibility === "public" || visibility === "protected") {
81
+ if (member.kind === "field") {
82
+ // A visible field needs no @Accessor to be read/written from outside the
83
+ // mixin, but mixin code still needs @Shadow to reference the field.
84
+ return {
85
+ suggestedAnnotation: "@Shadow",
86
+ reasoning: `Target field "${member.name}" is ${visibility}; no @Accessor is needed to access it from outside, but the mixin still needs @Shadow to reference it.`,
87
+ exampleSnippet: buildExampleSnippet("@Shadow", member, match, mixinMemberName)
88
+ };
89
+ }
79
90
  return {
80
91
  suggestedAnnotation: "@Inject-only",
81
- reasoning: `Target "${member.name}" is ${visibility}; it is already visible to the mixin without @Shadow. Use @Inject directly.`,
92
+ reasoning: `Target method "${member.name}" is ${visibility}; it is already visible to the mixin, so no @Invoker is needed. Use @Inject to hook it, or @Shadow to call it from mixin code.`,
82
93
  exampleSnippet: buildExampleSnippet("@Inject-only", member, match, mixinMemberName)
83
94
  };
84
95
  }
@@ -99,30 +110,30 @@ function inferAnnotation(member, match, mixinMemberName) {
99
110
  }
100
111
  return {
101
112
  suggestedAnnotation: "@Shadow",
102
- reasoning: `Target "${member.name}" is private. mixin member "${mixinMemberName}" does not match accessor/invoker prefix conventions; @Shadow is appropriate.`,
113
+ reasoning: `Target "${member.name}" is ${visibility}. mixin member "${mixinMemberName}" does not match accessor/invoker prefix conventions; @Shadow is appropriate.`,
103
114
  exampleSnippet: buildExampleSnippet("@Shadow", member, match, mixinMemberName)
104
115
  };
105
116
  }
106
117
  if (member.kind === "field") {
107
118
  return {
108
119
  suggestedAnnotation: "@Shadow",
109
- reasoning: `Target "${member.name}" is private; use @Shadow to access it from the mixin.`,
120
+ reasoning: `Target "${member.name}" is ${visibility}; use @Shadow to access it from the mixin.`,
110
121
  exampleSnippet: buildExampleSnippet("@Shadow", member, match, mixinMemberName)
111
122
  };
112
123
  }
113
124
  return {
114
125
  suggestedAnnotation: null,
115
- reasoning: `Target method "${member.name}" is private and no mixinMemberName was supplied; cannot decide between @Shadow and @Invoker. Choose @Shadow when the mixin overrides the method, @Invoker when it only needs to call it.`,
126
+ reasoning: `Target method "${member.name}" is ${visibility} and no mixinMemberName was supplied; cannot decide between @Shadow and @Invoker. Choose @Shadow when the mixin overrides the method, @Invoker when it only needs to call it.`,
116
127
  exampleSnippet: "// Provide mixinMemberName to receive a concrete @Shadow or @Invoker template.",
117
128
  candidates: [
118
129
  {
119
130
  annotation: "@Shadow",
120
- reasoning: "Use @Shadow to override or read the private method body from the mixin.",
131
+ reasoning: `Use @Shadow to override or read the ${visibility} method body from the mixin.`,
121
132
  exampleSnippet: buildExampleSnippet("@Shadow", member, match, mixinMemberName)
122
133
  },
123
134
  {
124
135
  annotation: "@Invoker",
125
- reasoning: "Use @Invoker when the mixin only needs to invoke the private method without redeclaring the body.",
136
+ reasoning: `Use @Invoker when the mixin only needs to invoke the ${visibility} method without redeclaring the body.`,
126
137
  exampleSnippet: buildExampleSnippet("@Invoker", member, match, mixinMemberName)
127
138
  }
128
139
  ]
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
@@ -688,6 +710,7 @@ function buildServer(ctx) {
688
710
  access: input.access,
689
711
  includeSynthetic: input.includeSynthetic,
690
712
  includeInherited: input.includeInherited,
713
+ includeAnnotations: input.includeAnnotations,
691
714
  memberPattern: input.memberPattern,
692
715
  maxMembers: input.maxMembers,
693
716
  projection: input.projection,
@@ -1,5 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  export declare const MAX_STDIO_SNAPSHOT = 6240;
3
+ export declare const JAVA_PROCESS_KILL_GRACE_MS = 3000;
3
4
  export interface JavaProcessOptions {
4
5
  jarPath: string;
5
6
  args: string[];
@@ -3,6 +3,11 @@ import { createError, ERROR_CODES } from "./errors.js";
3
3
  import { normalizePathForHost } from "./path-converter.js";
4
4
  const JAVA_CHECK_TIMEOUT_MS = 2_000;
5
5
  export const MAX_STDIO_SNAPSHOT = 6_240;
6
+ // How long to wait after SIGTERM before escalating to SIGKILL on a timed-out
7
+ // Java process. This is a single leaf process (not a process tree — the
8
+ // supervisor's tree-termination path is a separate concern), so a direct
9
+ // SIGKILL here is the sanctioned escalation, not a workaround.
10
+ export const JAVA_PROCESS_KILL_GRACE_MS = 3_000;
6
11
  let javaAvailabilitySpawn = spawn;
7
12
  let javaAvailabilityPromise;
8
13
  export function limitStdio(text) {
@@ -95,8 +100,15 @@ export function runJavaProcess(options) {
95
100
  });
96
101
  let stdout = "";
97
102
  let stderr = "";
103
+ let killTimer;
98
104
  const timer = setTimeout(() => {
99
105
  proc.kill();
106
+ killTimer = setTimeout(() => {
107
+ // The process ignored SIGTERM past the grace period; escalate to
108
+ // SIGKILL so a hung JVM cannot outlive the timeout indefinitely.
109
+ proc.kill("SIGKILL");
110
+ }, JAVA_PROCESS_KILL_GRACE_MS);
111
+ killTimer.unref();
100
112
  reject(createError({
101
113
  code: ERROR_CODES.JAVA_PROCESS_FAILED,
102
114
  message: "Java process timed out.",
@@ -118,6 +130,7 @@ export function runJavaProcess(options) {
118
130
  });
119
131
  proc.once("error", (error) => {
120
132
  clearTimeout(timer);
133
+ clearTimeout(killTimer);
121
134
  reject(createError({
122
135
  code: ERROR_CODES.JAVA_PROCESS_FAILED,
123
136
  message: "Java process failed to start.",
@@ -129,6 +142,7 @@ export function runJavaProcess(options) {
129
142
  });
130
143
  proc.once("close", (code) => {
131
144
  clearTimeout(timer);
145
+ clearTimeout(killTimer);
132
146
  resolve({
133
147
  exitCode: code ?? -1,
134
148
  stdoutTail: limitStdio(stdout),
@@ -55,6 +55,15 @@ export function lookupCandidates(index, query) {
55
55
  addCandidates(collected, index, index.simple.get(key), "simple-name", 0.75);
56
56
  }
57
57
  return [...collected.values()]
58
+ // A lookup answers only with candidates of the query's own kind. The index buckets
59
+ // deliberately mix kinds: `exactLookupKeys` registers every method a second time
60
+ // under the descriptorless `owner.name` key that a FIELD's symbol also uses, and
61
+ // `simpleLookupKeys` files classes, fields and methods under the same bare name. So
62
+ // a field query could be answered with a same-named method's target name at
63
+ // exact/confidence 1, or with a class that merely shares its simple name. Filtering
64
+ // here — before the MAX_CANDIDATES slice — also keeps wrong-kind rows from crowding
65
+ // the right one out of a busy name's bucket.
66
+ .filter((candidate) => candidate.record.kind === query.kind)
58
67
  .sort((left, right) => {
59
68
  if (right.confidence !== left.confidence) {
60
69
  return right.confidence - left.confidence;
@@ -415,14 +424,20 @@ export function projectLookupCandidateDescriptor(candidate, sourceDescriptor, ta
415
424
  // namespace. Multi-hop paths that already produced a target-side descriptor are left
416
425
  // unchanged by design.
417
426
  if (candidate.kind !== "method" ||
427
+ !candidate.owner ||
418
428
  !candidate.descriptor ||
419
429
  !targetDescriptor ||
420
430
  candidate.descriptor !== sourceDescriptor) {
421
431
  return candidate;
422
432
  }
433
+ // `symbol` embeds the descriptor, so replacing only the `descriptor` field left the
434
+ // SOURCE-namespace descriptor inside an otherwise target-namespace symbol — one
435
+ // SymbolReference advertising two namespaces at once. Rebuild it from the target
436
+ // owner + name + descriptor with the same helper the parsers use.
423
437
  return {
424
438
  ...candidate,
425
- descriptor: targetDescriptor
439
+ descriptor: targetDescriptor,
440
+ symbol: createMethodSymbolRecord(candidate.owner, candidate.name, targetDescriptor).symbol
426
441
  };
427
442
  }
428
443
  export function effectiveLoomSearchProjectPath(projectPath) {
@@ -56,6 +56,20 @@ export declare class MappingService {
56
56
  * accepted alias so an owner that the mapping does not rename still matches itself.
57
57
  */
58
58
  private acceptedOwnersAlongPath;
59
+ /**
60
+ * The descriptor forms a stored record may legitimately carry for a query written
61
+ * in `sourceMapping` coordinates: the caller's own descriptor, the target-space
62
+ * projection, and the projection into each base namespace.
63
+ *
64
+ * Tiny v2 writes one descriptor per method row, in the file's FIRST namespace, and
65
+ * shares it across every column. A merged Loom rendering puts that in obfuscated
66
+ * coordinates, but a workspace whose `official intermediary` and `intermediary named`
67
+ * mappings are SEPARATE files leaves the last hop's record carrying an
68
+ * intermediary-coordinate descriptor, which an obfuscated-only accepted set rejected
69
+ * as `not_found`. Projecting along every reachable base namespace is the same rule
70
+ * `checkSymbolExists` applies.
71
+ */
72
+ private acceptedDescriptorForms;
59
73
  private projectMethodDescriptorToTarget;
60
74
  private provenanceForPath;
61
75
  /**
@@ -193,15 +193,10 @@ export class MappingService {
193
193
  // only against the fully projected target descriptor dropped those valid candidates
194
194
  // and produced false `not_found` for common Mojang -> Yarn lookups. Accept any
195
195
  // candidate whose descriptor matches the caller's descriptor, the target-space
196
- // projection, or the obfuscated-space projection — the three forms that actually
197
- // appear in the mapping graph.
196
+ // projection, or a base-namespace projection — the forms that actually appear in
197
+ // the mapping graph.
198
198
  const strictDescriptor = projectedDescriptor ?? queryRecord.descriptor;
199
- const acceptedDescriptors = new Set([queryRecord.descriptor, strictDescriptor]);
200
- const toObfuscatedPath = namespacePath(graph, sourceMapping, "obfuscated");
201
- if (toObfuscatedPath) {
202
- const obfuscatedProjection = this.projectMethodDescriptorToTarget(graph, toObfuscatedPath, queryRecord.descriptor);
203
- acceptedDescriptors.add(obfuscatedProjection.descriptor);
204
- }
199
+ const acceptedDescriptors = this.acceptedDescriptorForms(graph, sourceMapping, queryRecord.descriptor, strictDescriptor);
205
200
  rawCandidates = rawCandidates.filter((candidate) => candidate.descriptor !== undefined && acceptedDescriptors.has(candidate.descriptor));
206
201
  }
207
202
  const disambiguatedCandidates = applyDisambiguationHints(rawCandidates, input.disambiguation, warnings);
@@ -402,12 +397,7 @@ export class MappingService {
402
397
  const candidates = rawCandidates.map(toResolutionCandidate);
403
398
  const limitedCandidates = limitResolutionCandidates(candidates, input.maxCandidates);
404
399
  const strictDescriptor = projectedDescriptor ?? descriptor;
405
- const acceptedDescriptors = new Set([descriptor, strictDescriptor]);
406
- const toObfuscatedPath = namespacePath(graph, sourceMapping, "obfuscated");
407
- if (toObfuscatedPath) {
408
- const obfuscatedProjection = this.projectMethodDescriptorToTarget(graph, toObfuscatedPath, descriptor);
409
- acceptedDescriptors.add(obfuscatedProjection.descriptor);
410
- }
400
+ const acceptedDescriptors = this.acceptedDescriptorForms(graph, sourceMapping, descriptor, strictDescriptor);
411
401
  // "Strict" is the query's full advertised triple: owner + name + descriptor. The
412
402
  // descriptor alone is not enough, because `lookupCandidates` also indexes methods
413
403
  // under the OWNERLESS `<name><descriptor>` simple-name key — so a same-signature
@@ -1069,6 +1059,33 @@ export class MappingService {
1069
1059
  }
1070
1060
  return accepted;
1071
1061
  }
1062
+ /**
1063
+ * The descriptor forms a stored record may legitimately carry for a query written
1064
+ * in `sourceMapping` coordinates: the caller's own descriptor, the target-space
1065
+ * projection, and the projection into each base namespace.
1066
+ *
1067
+ * Tiny v2 writes one descriptor per method row, in the file's FIRST namespace, and
1068
+ * shares it across every column. A merged Loom rendering puts that in obfuscated
1069
+ * coordinates, but a workspace whose `official intermediary` and `intermediary named`
1070
+ * mappings are SEPARATE files leaves the last hop's record carrying an
1071
+ * intermediary-coordinate descriptor, which an obfuscated-only accepted set rejected
1072
+ * as `not_found`. Projecting along every reachable base namespace is the same rule
1073
+ * `checkSymbolExists` applies.
1074
+ */
1075
+ acceptedDescriptorForms(graph, sourceMapping, descriptor, targetDescriptor) {
1076
+ const accepted = new Set([descriptor, targetDescriptor]);
1077
+ for (const baseNamespace of ["obfuscated", "intermediary"]) {
1078
+ if (sourceMapping === baseNamespace) {
1079
+ continue;
1080
+ }
1081
+ const projectionPath = namespacePath(graph, sourceMapping, baseNamespace);
1082
+ if (!projectionPath) {
1083
+ continue;
1084
+ }
1085
+ accepted.add(this.projectMethodDescriptorToTarget(graph, projectionPath, descriptor).descriptor);
1086
+ }
1087
+ return accepted;
1088
+ }
1072
1089
  projectMethodDescriptorToTarget(graph, path, descriptor) {
1073
1090
  let hadClassReferences = false;
1074
1091
  let complete = true;
@@ -1156,7 +1173,10 @@ export class MappingService {
1156
1173
  return {
1157
1174
  mojangMappingsAvailable: false,
1158
1175
  tinyMappingsAvailable: !needsTinyMappings,
1159
- memberRemapAvailable: false,
1176
+ // Obfuscated requests never need a remap path (the target is already
1177
+ // obfuscated), same as the loaded-graph branch below — a failed graph
1178
+ // load does not degrade an obfuscated request's remap availability.
1179
+ memberRemapAvailable: input.requestedMapping === "obfuscated",
1160
1180
  degradations: ["Mapping graph could not be loaded."]
1161
1181
  };
1162
1182
  }
@@ -1,5 +1,5 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { createError, ERROR_CODES } from "./errors.js";
2
+ import { createError, ERROR_CODES, isAppError } from "./errors.js";
3
3
  import { loadConfig } from "./config.js";
4
4
  import { normalizeJarPath, readJarStatStamp } from "./path-resolver.js";
5
5
  import { createJarEntryReader } from "./source-jar-reader.js";
@@ -447,22 +447,44 @@ class ByteReader {
447
447
  constructor(buffer) {
448
448
  this.buffer = buffer;
449
449
  }
450
+ // Mirrors NbtReader's ensure() (src/nbt/java-nbt-codec.ts). Without this,
451
+ // Buffer.subarray silently clips a length past EOF instead of throwing, so
452
+ // a truncated attribute body (declared length intact, trailing bytes
453
+ // missing) reads as a shorter-than-declared Buffer and the offset still
454
+ // advances past the true end - readBytes "succeeds" on corrupted data.
455
+ ensure(length) {
456
+ if (this.offset + length > this.buffer.length) {
457
+ throw createError({
458
+ code: ERROR_CODES.INTERNAL,
459
+ message: "Unexpected end of class file data. Class file may be corrupted.",
460
+ details: {
461
+ offset: this.offset,
462
+ requiredBytes: length,
463
+ remainingBytes: this.buffer.length - this.offset
464
+ }
465
+ });
466
+ }
467
+ }
450
468
  readU1() {
469
+ this.ensure(1);
451
470
  const value = this.buffer.readUInt8(this.offset);
452
471
  this.offset += 1;
453
472
  return value;
454
473
  }
455
474
  readU2() {
475
+ this.ensure(2);
456
476
  const value = this.buffer.readUInt16BE(this.offset);
457
477
  this.offset += 2;
458
478
  return value;
459
479
  }
460
480
  readU4() {
481
+ this.ensure(4);
461
482
  const value = this.buffer.readUInt32BE(this.offset);
462
483
  this.offset += 4;
463
484
  return value;
464
485
  }
465
486
  readBytes(length) {
487
+ this.ensure(length);
466
488
  const slice = this.buffer.subarray(this.offset, this.offset + length);
467
489
  this.offset += length;
468
490
  return slice;
@@ -773,7 +795,26 @@ export class MinecraftExplorerService {
773
795
  details: { fqn, jarPath, classEntryPath }
774
796
  });
775
797
  }
776
- parsed = parseClassFile(classBuffer);
798
+ try {
799
+ parsed = parseClassFile(classBuffer);
800
+ }
801
+ catch (error) {
802
+ // A primary-class parse failure of ANY origin - a raw Buffer bounds
803
+ // error (ByteReader has no bounds checks), or one of parseClassFile's
804
+ // own deliberate throws (bad magic, unsupported constant-pool tag) -
805
+ // is third-party jar content, never blamed on the caller as
806
+ // ERR_INTERNAL. An AppError that already carries ERR_CLASS_NOT_FOUND
807
+ // (readUtf8/readClassName) is already in the right shape and passes
808
+ // through unchanged.
809
+ if (isAppError(error) && error.code === ERROR_CODES.CLASS_NOT_FOUND) {
810
+ throw error;
811
+ }
812
+ throw createError({
813
+ code: ERROR_CODES.CLASS_NOT_FOUND,
814
+ message: `Class "${fqn}" in "${jarPath}" is malformed or truncated. Class file may be corrupted.`,
815
+ details: { fqn, jarPath, classEntryPath }
816
+ });
817
+ }
777
818
  }
778
819
  catch (error) {
779
820
  reader.close();
@@ -863,12 +904,23 @@ export class MinecraftExplorerService {
863
904
  reader.close();
864
905
  const toSignatureMember = (ownerFqn, ownerSimpleClassName, member, category) => {
865
906
  if (category === "field") {
866
- const parsedField = parseFieldType(member.descriptor, 0, { allowVoid: false });
867
- if (parsedField.next !== member.descriptor.length) {
907
+ // parseFieldType/parseMethodDescriptor throw ERR_INVALID_INPUT, which
908
+ // blames the caller; a member descriptor comes from third-party
909
+ // bytecode, not tool input, so a malformed one is treated like the
910
+ // corrupted-class failure above (src/source/descriptor-utils.ts
911
+ // degrades the same parsers rather than throwing at all).
912
+ let parsedField;
913
+ try {
914
+ parsedField = parseFieldType(member.descriptor, 0, { allowVoid: false });
915
+ if (parsedField.next !== member.descriptor.length) {
916
+ throw new Error("Trailing bytes after field descriptor.");
917
+ }
918
+ }
919
+ catch {
868
920
  throw createError({
869
- code: ERROR_CODES.INVALID_INPUT,
870
- message: `Invalid field descriptor "${member.descriptor}".`,
871
- details: { descriptor: member.descriptor, position: parsedField.next }
921
+ code: ERROR_CODES.CLASS_NOT_FOUND,
922
+ message: `Field "${member.name}" in "${ownerFqn}" has a malformed descriptor in "${jarPath}". Class file may be corrupted.`,
923
+ details: { ownerFqn, jarPath, member: member.name, descriptor: member.descriptor }
872
924
  });
873
925
  }
874
926
  const fieldType = parsedField.type;
@@ -884,7 +936,17 @@ export class MinecraftExplorerService {
884
936
  ...(member.annotations ? { annotations: member.annotations } : {})
885
937
  };
886
938
  }
887
- const parsedMethod = parseMethodDescriptor(member.descriptor);
939
+ let parsedMethod;
940
+ try {
941
+ parsedMethod = parseMethodDescriptor(member.descriptor);
942
+ }
943
+ catch {
944
+ throw createError({
945
+ code: ERROR_CODES.CLASS_NOT_FOUND,
946
+ message: `Method "${member.name}" in "${ownerFqn}" has a malformed descriptor in "${jarPath}". Class file may be corrupted.`,
947
+ details: { ownerFqn, jarPath, member: member.name, descriptor: member.descriptor }
948
+ });
949
+ }
888
950
  const modifiers = modifierPrefix(member.accessFlags, "method");
889
951
  const args = parsedMethod.args.join(", ");
890
952
  if (member.name === "<init>") {
@@ -1,10 +1,46 @@
1
1
  import { accessLevelFromFlags, allFieldNames, allMethodNames, suggestSimilar } from "./helpers.js";
2
+ const VALID_AW_HEADER_VERSIONS = new Set(["v1", "v2"]);
3
+ /**
4
+ * Fabric access widener grammar rules (AUTHORITY): `accessible` applies to
5
+ * class, method, field; `extendable` applies to class and method only;
6
+ * `mutable` applies to field only; `transitive-` exists only under a v2
7
+ * header. Returns a line+rule message when `entry` breaks one of these, or
8
+ * undefined when the pairing is legal.
9
+ */
10
+ function checkAccessWidenerEntryLegality(entry, headerVersion) {
11
+ if (entry.kind === "mutable" && entry.targetKind !== "field") {
12
+ return `Line ${entry.line}: "mutable" only applies to field, not ${entry.targetKind}.`;
13
+ }
14
+ if (entry.kind === "extendable" && entry.targetKind === "field") {
15
+ return `Line ${entry.line}: "extendable" does not apply to field (only class and method).`;
16
+ }
17
+ if (entry.transitive && headerVersion !== "v2") {
18
+ return `Line ${entry.line}: "transitive-" entries require a v2 header (declared "${headerVersion || "none"}").`;
19
+ }
20
+ return undefined;
21
+ }
2
22
  export function validateParsedAccessWidener(parsed, membersByClass, warnings, options) {
3
23
  warnings.push(...parsed.parseWarnings);
24
+ let headerValid = true;
25
+ const headerLine = parsed.headerLine ?? 1;
26
+ if (!parsed.headerVersion) {
27
+ headerValid = false;
28
+ warnings.push(`Line ${headerLine}: accessWidener requires a header line ("accessWidener <v1|v2> <namespace>"); none was found.`);
29
+ }
30
+ else if (!VALID_AW_HEADER_VERSIONS.has(parsed.headerVersion)) {
31
+ headerValid = false;
32
+ warnings.push(`Line ${headerLine}: Unsupported accessWidener header version "${parsed.headerVersion}" (expected v1 or v2).`);
33
+ }
4
34
  const validatedEntries = [];
5
35
  let validCount = 0;
6
36
  let invalidCount = 0;
7
37
  for (const entry of parsed.entries) {
38
+ const legalityIssue = checkAccessWidenerEntryLegality(entry, parsed.headerVersion);
39
+ if (legalityIssue) {
40
+ validatedEntries.push({ ...entry, valid: false, issue: legalityIssue });
41
+ invalidCount++;
42
+ continue;
43
+ }
8
44
  const ownerFqn = entry.target.replace(/\//g, ".");
9
45
  if (entry.targetKind === "class") {
10
46
  const members = membersByClass.get(ownerFqn);
@@ -130,7 +166,7 @@ export function validateParsedAccessWidener(parsed, membersByClass, warnings, op
130
166
  return {
131
167
  headerVersion: parsed.headerVersion,
132
168
  namespace: parsed.namespace,
133
- valid: invalidCount === 0,
169
+ valid: invalidCount === 0 && headerValid && (parsed.rejectedEntryCount ?? 0) === 0,
134
170
  entries: validatedEntries,
135
171
  summary: {
136
172
  total: parsed.entries.length,
@@ -276,7 +312,7 @@ export function validateParsedAccessTransformer(parsed, membersByClass, warnings
276
312
  validCount++;
277
313
  }
278
314
  return {
279
- valid: invalidCount === 0,
315
+ valid: invalidCount === 0 && (parsed.rejectedEntryCount ?? 0) === 0,
280
316
  entries: validatedEntries,
281
317
  summary: {
282
318
  total: parsed.entries.length,