@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
@@ -4,6 +4,7 @@ import { performance } from "node:perf_hooks";
4
4
  import fastGlob from "fast-glob";
5
5
  import { ERROR_CODES, createError, isAppError } from "../errors.js";
6
6
  import { loadMixinStageBudgets, refreshMixinValidationOutcome, validateParsedMixin } from "../mixin-validator.js";
7
+ import { selectNestedMixin } from "../mixin-parser.js";
7
8
  import { normalizePathForHost } from "../path-converter.js";
8
9
  import { NOOP_STAGE_EMITTER } from "../stage-emitter.js";
9
10
  import { createMixinPipelineContext } from "./validate-mixin/pipeline-context.js";
@@ -262,11 +263,12 @@ async function runValidateMixinDispatcher(svc, rawInput, options = {}) {
262
263
  return validateMixinMany(svc, mode, configSources.map((entry) => ({
263
264
  source: {
264
265
  kind: "config",
265
- label: entry.sourcePath,
266
+ label: (entry.nestedClassPath?.length ?? 0) > 1 ? `${entry.sourcePath} (${entry.entry})` : entry.sourcePath,
266
267
  path: entry.sourcePath,
267
268
  configPath: entry.configPath
268
269
  },
269
- sourcePath: entry.sourcePath
270
+ sourcePath: entry.sourcePath,
271
+ nestedClassPath: entry.nestedClassPath
270
272
  })), resolvedInput, configWarnings, sharedSingleOptions);
271
273
  }
272
274
  async function createProjectValidateMixinConfigInput(input) {
@@ -299,7 +301,7 @@ async function createProjectValidateMixinConfigInput(input) {
299
301
  }
300
302
  };
301
303
  }
302
- function shouldRetryValidateMixinWithMavenFirst(svc, input, result) {
304
+ function shouldRetryValidateMixinWithMavenFirst(svc, input, result, eligibilityIssues) {
303
305
  const initialPriority = input.retryState?.initialSourcePriority ?? input.sourcePriority ?? svc.config.mappingSourcePriority;
304
306
  if (input.retryState?.attempted || initialPriority !== "loom-first") {
305
307
  return false;
@@ -316,7 +318,9 @@ function shouldRetryValidateMixinWithMavenFirst(svc, input, result) {
316
318
  if (result.summary.membersSkipped > 0) {
317
319
  return true;
318
320
  }
319
- return result.issues.some((issue) => issue.resolutionPath === "source-signature-unavailable" ||
321
+ // eligibilityIssues is the pre-category-filter issue list: a display
322
+ // filter (warningCategoryFilter) must not change whether a retry happens.
323
+ return eligibilityIssues.some((issue) => issue.resolutionPath === "source-signature-unavailable" ||
320
324
  issue.resolutionPath === "target-mapping-failed" ||
321
325
  issue.resolutionPath === "member-remap-failed");
322
326
  }
@@ -401,6 +405,20 @@ async function runValidateMixinPipeline(svc, seed) {
401
405
  await runResolveStage(svc, ctx);
402
406
  await runMappingHealthStage(svc, ctx);
403
407
  await runParseStage(ctx);
408
+ const nestedClassPath = ctx.input.nestedClassPath;
409
+ if (nestedClassPath) {
410
+ const nested = selectNestedMixin(ctx.parsed, nestedClassPath);
411
+ // A top-level entry whose file has no located @Mixin class keeps the whole-file result.
412
+ const wholeFile = nestedClassPath.length === 1 && (ctx.parsed.mixins ?? []).length === 0;
413
+ if (!nested && !wholeFile) {
414
+ throw createError({
415
+ code: ERROR_CODES.INVALID_INPUT,
416
+ message: `No @Mixin class "${nestedClassPath.join("$")}" is declared in "${ctx.input.sourcePath ?? "<source>"}".`,
417
+ details: { failedStage: "parse" }
418
+ });
419
+ }
420
+ ctx.parsed = nested ?? ctx.parsed;
421
+ }
404
422
  await runTargetLookupStage(svc, ctx);
405
423
  return finalizeValidateMixinPipeline(svc, ctx);
406
424
  }
@@ -498,7 +516,25 @@ async function finalizeValidateMixinPipeline(svc, ctx) {
498
516
  };
499
517
  result.unfilteredSummary = unfilteredSummary;
500
518
  }
501
- if (input.warningCategoryFilter && input.warningCategoryFilter.length > 0) {
519
+ // Recompute the outcome as the severity/uncertainty filters above leave it
520
+ // (a no-op when neither minSeverity nor hideUncertain was set), BEFORE any
521
+ // category filtering narrows things further. This is the baseline a
522
+ // warningCategoryFilter must reproduce exactly: valid, validationStatus and
523
+ // quickSummary must come out identical with or without the category
524
+ // filter, and retry eligibility must be decided from this same
525
+ // pre-category-filter issue list (see work items: warningCategoryFilter
526
+ // must not report a target-not-found mixin as valid:true, and must not
527
+ // change maven-first retry eligibility).
528
+ refreshMixinValidationOutcome(result);
529
+ const issuesForRetryEligibility = result.issues;
530
+ const preCategoryFilterValid = result.valid;
531
+ const preCategoryFilterValidationStatus = result.validationStatus;
532
+ const preCategoryFilterQuickSummary = result.quickSummary;
533
+ const categoryFilterApplied = !!(input.warningCategoryFilter && input.warningCategoryFilter.length > 0);
534
+ if (categoryFilterApplied) {
535
+ if (!result.unfilteredSummary) {
536
+ result.unfilteredSummary = { ...result.summary };
537
+ }
502
538
  const allowedCategories = new Set(input.warningCategoryFilter);
503
539
  result.issues = result.issues.filter((i) => i.category && allowedCategories.has(i.category));
504
540
  if (result.structuredWarnings) {
@@ -540,7 +576,15 @@ async function finalizeValidateMixinPipeline(svc, ctx) {
540
576
  else {
541
577
  refreshMixinValidationOutcome(result);
542
578
  }
543
- if (shouldRetryValidateMixinWithMavenFirst(svc, input, result)) {
579
+ if (categoryFilterApplied) {
580
+ // refreshMixinValidationOutcome above recomputed valid/validationStatus/
581
+ // quickSummary from the category-filtered summary; restore the true
582
+ // (pre-category-filter) verdict now that member-count bookkeeping is done.
583
+ result.valid = preCategoryFilterValid;
584
+ result.validationStatus = preCategoryFilterValidationStatus;
585
+ result.quickSummary = preCategoryFilterQuickSummary;
586
+ }
587
+ if (shouldRetryValidateMixinWithMavenFirst(svc, input, result, issuesForRetryEligibility)) {
544
588
  const retryWarning = `Retrying validate-mixin with sourcePriority="maven-first" after partial validation using "${currentSourcePriority}".`;
545
589
  try {
546
590
  const retried = await svc.validateMixinSingle({
@@ -588,10 +632,10 @@ async function resolveMixinConfigSources(input) {
588
632
  const warnings = [];
589
633
  for (const rawConfigPath of input.input.configPaths) {
590
634
  const resolvedConfigPath = resolveMixinInputPath(rawConfigPath, "configPath");
591
- let configJson;
635
+ let parsedConfig;
592
636
  try {
593
637
  const raw = await readFile(resolvedConfigPath, "utf-8");
594
- configJson = JSON.parse(raw);
638
+ parsedConfig = JSON.parse(raw);
595
639
  }
596
640
  catch (err) {
597
641
  throw createError({
@@ -600,6 +644,44 @@ async function resolveMixinConfigSources(input) {
600
644
  details: { failedStage: "input-validation" }
601
645
  });
602
646
  }
647
+ if (parsedConfig === null || typeof parsedConfig !== "object" || Array.isArray(parsedConfig)) {
648
+ throw createError({
649
+ code: ERROR_CODES.INVALID_INPUT,
650
+ message: `Mixin config "${rawConfigPath}" must contain a JSON object.`,
651
+ details: { failedStage: "input-validation" }
652
+ });
653
+ }
654
+ const configRecord = parsedConfig;
655
+ for (const key of ["mixins", "client", "server"]) {
656
+ const fieldValue = configRecord[key];
657
+ if (fieldValue === undefined) {
658
+ continue;
659
+ }
660
+ if (!Array.isArray(fieldValue)) {
661
+ throw createError({
662
+ code: ERROR_CODES.INVALID_INPUT,
663
+ message: `Mixin config "${rawConfigPath}" field "${key}" must be an array of class names.`,
664
+ details: { failedStage: "input-validation" }
665
+ });
666
+ }
667
+ for (let i = 0; i < fieldValue.length; i++) {
668
+ if (typeof fieldValue[i] !== "string") {
669
+ throw createError({
670
+ code: ERROR_CODES.INVALID_INPUT,
671
+ message: `Mixin config "${rawConfigPath}" field "${key}" element at index ${i} must be a string.`,
672
+ details: { failedStage: "input-validation" }
673
+ });
674
+ }
675
+ }
676
+ }
677
+ if (configRecord.package !== undefined && typeof configRecord.package !== "string") {
678
+ throw createError({
679
+ code: ERROR_CODES.INVALID_INPUT,
680
+ message: `Mixin config "${rawConfigPath}" field "package" must be a string.`,
681
+ details: { failedStage: "input-validation" }
682
+ });
683
+ }
684
+ const configJson = configRecord;
603
685
  const pkg = configJson.package ?? "";
604
686
  const classNames = [
605
687
  ...(configJson.mixins ?? []),
@@ -622,10 +704,13 @@ async function resolveMixinConfigSources(input) {
622
704
  for (const candidateRoot of COMMON_SOURCE_ROOTS) {
623
705
  let foundInRoot = false;
624
706
  for (const className of classNames) {
625
- const fqcn = pkg ? `${pkg}.${className}` : className;
626
- const relative = fqcn.replace(/\./g, "/") + ".java";
627
- if (await pathExists(resolvePath(projectBase, candidateRoot, relative))) {
628
- foundInRoot = true;
707
+ for (const candidate of configEntrySourceCandidates(pkg, className)) {
708
+ if (await pathExists(resolvePath(projectBase, candidateRoot, candidate.relativePath))) {
709
+ foundInRoot = true;
710
+ break;
711
+ }
712
+ }
713
+ if (foundInRoot) {
629
714
  break;
630
715
  }
631
716
  }
@@ -636,19 +721,27 @@ async function resolveMixinConfigSources(input) {
636
721
  sourceRootCandidates = detected.length > 0 ? detected : ["src/main/java"];
637
722
  }
638
723
  for (const cls of classNames) {
639
- const fqcn = pkg ? `${pkg}.${cls}` : cls;
640
- const relativePath = fqcn.replace(/\./g, "/") + ".java";
641
- let sourcePath = resolvePath(projectBase, sourceRootCandidates[0], relativePath);
642
- for (const root of sourceRootCandidates) {
643
- const candidate = resolvePath(projectBase, root, relativePath);
644
- if (await pathExists(candidate)) {
645
- sourcePath = candidate;
724
+ // The entry's own file wins; a `$`-qualified entry without one is a nested class
725
+ // validated inside its outer file. An entry found nowhere keeps its own path.
726
+ const candidates = configEntrySourceCandidates(pkg, cls);
727
+ let resolved;
728
+ for (const candidate of candidates) {
729
+ for (const root of sourceRootCandidates) {
730
+ const sourcePath = resolvePath(projectBase, root, candidate.relativePath);
731
+ if (await pathExists(sourcePath)) {
732
+ resolved = { sourcePath, nestedClassPath: candidate.nestedClassPath };
733
+ break;
734
+ }
735
+ }
736
+ if (resolved) {
646
737
  break;
647
738
  }
648
739
  }
649
740
  results.push({
650
- sourcePath,
651
- configPath: resolvedConfigPath
741
+ sourcePath: resolved?.sourcePath ?? resolvePath(projectBase, sourceRootCandidates[0], candidates[0].relativePath),
742
+ configPath: resolvedConfigPath,
743
+ nestedClassPath: resolved?.nestedClassPath,
744
+ entry: cls
652
745
  });
653
746
  }
654
747
  }
@@ -657,6 +750,27 @@ async function resolveMixinConfigSources(input) {
657
750
  warnings
658
751
  };
659
752
  }
753
+ /**
754
+ * Source files a mixins-config entry may live in, in lookup order, each with the simple-name path
755
+ * of the entry's class inside it: the entry's own file (a one-element path, so nested mixins in
756
+ * that file are left to their own entries), then for a nested entry (`Outer$Inner`,
757
+ * `Outer$Mid$Inner`) the outer class's file.
758
+ */
759
+ function configEntrySourceCandidates(pkg, cls) {
760
+ const fqcn = pkg ? `${pkg}.${cls}` : cls;
761
+ const lastDot = fqcn.lastIndexOf(".");
762
+ const candidates = [
763
+ { relativePath: fqcn.replace(/\./g, "/") + ".java", nestedClassPath: [fqcn.slice(lastDot + 1)] }
764
+ ];
765
+ const classPath = fqcn.slice(lastDot + 1).split("$");
766
+ if (classPath.length > 1 && classPath.every((name) => name !== "")) {
767
+ candidates.push({
768
+ relativePath: `${fqcn.slice(0, lastDot + 1).replace(/\./g, "/")}${classPath[0]}.java`,
769
+ nestedClassPath: classPath
770
+ });
771
+ }
772
+ return candidates;
773
+ }
660
774
  async function validateMixinMany(svc, mode, entries, input, additionalWarnings, extras = {}) {
661
775
  const results = [];
662
776
  const batchWarningMode = input.warningMode ?? "aggregated";
@@ -670,6 +784,7 @@ async function validateMixinMany(svc, mode, entries, input, additionalWarnings,
670
784
  const singleResult = await svc.validateMixinSingle({
671
785
  ...sharedInput,
672
786
  sourcePath: entry.sourcePath,
787
+ nestedClassPath: entry.nestedClassPath,
673
788
  warningMode: batchWarningMode,
674
789
  batchCaches,
675
790
  stageEmitter,
@@ -1,15 +1,39 @@
1
+ import { resolve as resolvePath } from "node:path";
1
2
  import { buildSuggestedCall } from "../build-suggested-call.js";
2
3
  import { ERROR_CODES, createError } from "../errors.js";
4
+ import { normalizeOptionalProjectPath } from "../gradle-paths.js";
3
5
  import { describeSafeMavenSegmentRule, isSafeMavenSegment } from "../maven-token.js";
6
+ import { computeWorkspaceContextFingerprint } from "../workspace-context-cache.js";
4
7
  import { normalizeMapping } from "./shared-utils.js";
5
8
  // Env toggles are read at call time, not at module load: tests dynamically
6
9
  // re-import source-service.ts with a cache-busting query to flip the flag,
7
10
  // but cannot bust this module's cache through that path.
11
+ /**
12
+ * The root-level files `detectProjectMinecraftVersion`/`detectCompileMapping`/
13
+ * `detectProjectLoader` (src/workspace-mapping-service.ts) are guaranteed to
14
+ * read (or attempt to read) for every project, independent of what detection
15
+ * finds: `gradle.properties` by exact path, and `build.gradle`/`build.gradle.kts`
16
+ * by exact path as the first two entries of the build-script glob those two
17
+ * methods both run before descending into subdirectories. `settings.gradle(.kts)`
18
+ * and `libs.versions.toml` are not read by any of the three, so they are not
19
+ * listed here - see the coverage note on `fingerprintPaths` below.
20
+ */
21
+ function fixedDetectionFilenames() {
22
+ return ["gradle.properties", "build.gradle", "build.gradle.kts"];
23
+ }
8
24
  export async function loadOrDetectWorkspaceContext(svc, projectPath) {
9
25
  const cached = svc.workspaceContextCache.read(projectPath);
10
26
  if (cached && !cached.partial) {
11
27
  return cached;
12
28
  }
29
+ const projectRoot = resolvePath(projectPath);
30
+ // Snapshotted BEFORE detection runs, not after: an edit that lands while
31
+ // detection is mid-read (racing the very call below) must still show up as
32
+ // a stat difference on the next read. Fingerprinting after detection would
33
+ // record whatever is on disk at that later point - which could already be
34
+ // the edit that should have invalidated the entry, silently absorbing the
35
+ // race into a "the cache is fresh" fingerprint instead of catching it.
36
+ const fixedFingerprint = computeWorkspaceContextFingerprint(fixedDetectionFilenames().map((filename) => resolvePath(projectRoot, filename)));
13
37
  const [minecraftVersion, mappingResult, loaderResult] = await Promise.all([
14
38
  svc.workspaceMappingService.detectProjectMinecraftVersion(projectPath),
15
39
  svc.workspaceMappingService.detectCompileMapping({ projectPath }).catch(() => undefined),
@@ -38,6 +62,39 @@ export async function loadOrDetectWorkspaceContext(svc, projectPath) {
38
62
  });
39
63
  }
40
64
  const latest = svc.workspaceContextCache.read(projectPath);
65
+ // Fingerprint the files this detection actually read (or, for the fixed
66
+ // root set above, attempted to read regardless of outcome), so a later
67
+ // cache hit can catch an edit made within the TTL instead of serving a
68
+ // stale minecraftVersion/mapping/loader for up to five more minutes.
69
+ //
70
+ // Coverage: `fixedFingerprint` above (taken pre-detection) covers
71
+ // gradle.properties and a root-level build.gradle/build.gradle.kts,
72
+ // present or not - editing, creating, or deleting any of those three
73
+ // always invalidates. mapping/loader detection also globs subproject
74
+ // build scripts (`**/build.gradle(.kts)`) and, for the loader, mod
75
+ // descriptor files (`fabric.mod.json`, `quilt.mod.json`,
76
+ // `META-INF/{mods,neoforge.mods}.toml`) anywhere under the project; only
77
+ // the ones that actually produced a mapping/loader declaration are known
78
+ // here (as `evidence[].filePath`) and fingerprinted below. A subproject
79
+ // build script or descriptor that was scanned but declared nothing is NOT
80
+ // covered - editing one to newly declare a mapping/loader is not caught
81
+ // until the TTL expires, same as before this fingerprint existed.
82
+ // settings.gradle(.kts) and libs.versions.toml are not read by detection
83
+ // at all, so they are never fingerprinted.
84
+ const fixedPaths = new Set(fixedFingerprint.map((entry) => entry.path));
85
+ const evidencePaths = [
86
+ ...(mappingResult?.evidence.map((entry) => entry.filePath) ?? []),
87
+ ...(loaderResult?.evidence.map((entry) => entry.filePath) ?? [])
88
+ ].filter((path) => !fixedPaths.has(resolvePath(path)));
89
+ const fingerprint = [
90
+ ...fixedFingerprint,
91
+ // A path already covered by fixedFingerprint above (taken pre-detection)
92
+ // is skipped here rather than re-stat-ed post-detection: keeping only
93
+ // the earlier snapshot is what makes that entry catch a mid-detection
94
+ // race at all, and a second, later entry for the same path would just
95
+ // add a redundant stat on every read without changing the result.
96
+ ...computeWorkspaceContextFingerprint(evidencePaths)
97
+ ];
41
98
  const ctx = {
42
99
  projectPath,
43
100
  minecraftVersion,
@@ -46,7 +103,8 @@ export async function loadOrDetectWorkspaceContext(svc, projectPath) {
46
103
  detectedAt: Date.now(),
47
104
  evidence,
48
105
  dependencyVersions: latest?.dependencyVersions ?? cached?.dependencyVersions ?? new Map(),
49
- partial: false
106
+ partial: false,
107
+ fingerprint
50
108
  };
51
109
  svc.workspaceContextCache.write(ctx);
52
110
  return ctx;
@@ -61,7 +119,11 @@ export async function synthesizeWorkspaceTarget(svc, input, workspace) {
61
119
  }
62
120
  });
63
121
  }
64
- const projectPath = input.projectPath?.trim();
122
+ // Normalized once, here, and used for everything below: the workspace-context
123
+ // cache key, the detection reads and the provenance all have to name the same
124
+ // path. Trimming alone left a Windows-spelled path unreadable on a WSL host
125
+ // for this target kind while the sibling routes resolved it.
126
+ const projectPath = normalizeOptionalProjectPath(input.projectPath);
65
127
  if (!projectPath) {
66
128
  throw createError({
67
129
  code: ERROR_CODES.INVALID_INPUT,
@@ -86,10 +148,16 @@ export async function synthesizeWorkspaceTarget(svc, input, workspace) {
86
148
  nextAction: "Set minecraft_version in gradle.properties or pass target.kind=\"version\" with an explicit Minecraft version.",
87
149
  ...buildSuggestedCall({
88
150
  tool: "resolve-artifact",
89
- params: {
90
- target: { kind: "version", value: "<your-mc-version>" },
91
- projectPath
92
- }
151
+ params: undefined,
152
+ examples: [
153
+ {
154
+ params: {
155
+ target: { kind: "version", value: "<your-mc-version>" },
156
+ projectPath
157
+ },
158
+ reason: "Replace <your-mc-version> with the Minecraft version, or set minecraft_version in gradle.properties."
159
+ }
160
+ ]
93
161
  })
94
162
  }
95
163
  });
@@ -226,7 +294,7 @@ export async function synthesizeDependencyTarget(svc, input, dep) {
226
294
  }
227
295
  });
228
296
  }
229
- const projectPath = input.projectPath?.trim();
297
+ const projectPath = normalizeOptionalProjectPath(input.projectPath);
230
298
  if (!projectPath) {
231
299
  throw createError({
232
300
  code: ERROR_CODES.INVALID_INPUT,
@@ -53,6 +53,39 @@ export declare class EntryTooLargeError extends Error {
53
53
  export declare function listJarEntries(jarPath: string): Promise<string[]>;
54
54
  export declare function listJavaEntries(jarPath: string): Promise<string[]>;
55
55
  export declare function hasAnyJarEntry(jarPath: string, predicate: (entryPath: string) => boolean): Promise<boolean>;
56
+ /** Root entry in which a Minecraft runtime jar names its own release (`"id": "26.2"`). */
57
+ export declare const MINECRAFT_VERSION_JSON_ENTRY = "version.json";
58
+ /** Class every Minecraft runtime jar ships, under its Mojang name, from 26.1 on. */
59
+ export declare const MINECRAFT_SHARED_CONSTANTS_ENTRY = "net/minecraft/SharedConstants.class";
60
+ /**
61
+ * What an archive walk saw that bears on "is this the Minecraft runtime jar?".
62
+ * Raw observations only: judging them is the resolver's job.
63
+ */
64
+ export interface MinecraftRuntimeJarSignals {
65
+ hasSharedConstantsClass: boolean;
66
+ /**
67
+ * Text of the root `version.json`; absent when the entry is missing, larger
68
+ * than the bound, unreadable, or not UTF-8.
69
+ */
70
+ versionJsonText?: string;
71
+ }
72
+ export interface JavaSourceScan {
73
+ hasJavaSources: boolean;
74
+ /** Set only when the walk reached the end without meeting a `.java` entry. */
75
+ minecraftRuntimeSignals?: MinecraftRuntimeJarSignals;
76
+ }
77
+ /**
78
+ * `hasAnyJarEntry(jarPath, hasJavaSourceExtension)` that also collects the
79
+ * Minecraft runtime signals on the same walk.
80
+ *
81
+ * A jar without sources is already walked to its last entry to prove it has none,
82
+ * so noting two entry names on the way costs no extra open and no extra pass. The
83
+ * one entry read, `version.json`, uses the handle that is already open. A failure
84
+ * to read it only withholds the signal: it is evidence for a mapping decision, not
85
+ * the archive's verdict, so it never fails the scan. Errors opening or walking the
86
+ * archive propagate exactly as they do from `hasAnyJarEntry`.
87
+ */
88
+ export declare function scanJarForJavaSources(jarPath: string): Promise<JavaSourceScan>;
56
89
  export declare function readJarEntryAsUtf8(jarPath: string, entryPath: string): Promise<string>;
57
90
  /**
58
91
  * Reads one entry fully into memory. `maxBytes` bounds that materialization and
@@ -109,6 +142,20 @@ export interface JarEntryBuffer {
109
142
  export interface JarReaderOpenDeps {
110
143
  openZipFile?: (jarPath: string) => Promise<ZipFile>;
111
144
  }
145
+ export interface CollectMatchedJarEntriesAsBuffersOptions extends CollectMatchedJarEntriesOptions {
146
+ /**
147
+ * Checked only after an entry has been SUCCESSFULLY read and pushed onto the
148
+ * result (the success path), with the result array so far; returning true
149
+ * stops the walk immediately, alongside `maxEntries` (whichever triggers
150
+ * first). Unlike a predicate — which runs before a read is even attempted —
151
+ * this lets a caller implement a stop condition that depends on which reads
152
+ * actually succeeded, e.g. "stop once every distinct wanted name has at
153
+ * least one successful read", so one unreadable copy of a duplicate-named
154
+ * entry (with `continueOnError: true`) does not block a later readable copy
155
+ * of the same name from ever being tried.
156
+ */
157
+ stopWhen?: (collected: readonly JarEntryBuffer[]) => boolean;
158
+ }
112
159
  /**
113
160
  * Raw-buffer counterpart of {@link collectMatchedJarEntriesAsUtf8}: opens the jar
114
161
  * ONCE and returns the matched entries as raw Buffers (no UTF-8 decode), so binary
@@ -116,7 +163,7 @@ export interface JarReaderOpenDeps {
116
163
  * sampled entry. Mirrors the sibling's maxBytes/maxEntries/continueOnError semantics
117
164
  * and its finally-close, but takes an injectable open seam for open-count assertions.
118
165
  */
119
- export declare function collectMatchedJarEntriesAsBuffers(jarPath: string, predicate: (entryPath: string) => boolean, options?: CollectMatchedJarEntriesOptions, deps?: JarReaderOpenDeps): Promise<JarEntryBuffer[]>;
166
+ export declare function collectMatchedJarEntriesAsBuffers(jarPath: string, predicate: (entryPath: string) => boolean, options?: CollectMatchedJarEntriesAsBuffersOptions, deps?: JarReaderOpenDeps): Promise<JarEntryBuffer[]>;
120
167
  export declare function iterateJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): AsyncGenerator<JavaEntryText>;
121
168
  export declare function readAllJavaEntriesAsUtf8(jarPath: string, maxBytes?: number): Promise<JavaEntryText[]>;
122
169
  export declare function detectFabricLikeInputNamespace(inputJar: string, deps?: JarReaderOpenDeps): Promise<{
@@ -183,6 +183,64 @@ export async function hasAnyJarEntry(jarPath, predicate) {
183
183
  }
184
184
  });
185
185
  }
186
+ /** Root entry in which a Minecraft runtime jar names its own release (`"id": "26.2"`). */
187
+ export const MINECRAFT_VERSION_JSON_ENTRY = "version.json";
188
+ /** Class every Minecraft runtime jar ships, under its Mojang name, from 26.1 on. */
189
+ export const MINECRAFT_SHARED_CONSTANTS_ENTRY = "net/minecraft/SharedConstants.class";
190
+ /** The real file is under 1 KiB; anything far larger is not the file this looks for. */
191
+ const MAX_VERSION_JSON_BYTES = 64 * 1024;
192
+ /**
193
+ * `hasAnyJarEntry(jarPath, hasJavaSourceExtension)` that also collects the
194
+ * Minecraft runtime signals on the same walk.
195
+ *
196
+ * A jar without sources is already walked to its last entry to prove it has none,
197
+ * so noting two entry names on the way costs no extra open and no extra pass. The
198
+ * one entry read, `version.json`, uses the handle that is already open. A failure
199
+ * to read it only withholds the signal: it is evidence for a mapping decision, not
200
+ * the archive's verdict, so it never fails the scan. Errors opening or walking the
201
+ * archive propagate exactly as they do from `hasAnyJarEntry`.
202
+ */
203
+ export async function scanJarForJavaSources(jarPath) {
204
+ return withZipFile(jarPath, async (zipFile) => {
205
+ let hasSharedConstantsClass = false;
206
+ let versionJsonEntry;
207
+ while (true) {
208
+ const entry = await readNextEntry(zipFile);
209
+ if (!entry) {
210
+ break;
211
+ }
212
+ if (!isSecureJarEntryPath(entry.fileName)) {
213
+ continue;
214
+ }
215
+ if (hasJavaSourceExtension(entry.fileName)) {
216
+ return { hasJavaSources: true };
217
+ }
218
+ if (entry.fileName === MINECRAFT_SHARED_CONSTANTS_ENTRY) {
219
+ hasSharedConstantsClass = true;
220
+ }
221
+ else if (entry.fileName === MINECRAFT_VERSION_JSON_ENTRY) {
222
+ versionJsonEntry = entry;
223
+ }
224
+ }
225
+ let versionJsonText;
226
+ if (versionJsonEntry && versionJsonEntry.uncompressedSize <= MAX_VERSION_JSON_BYTES) {
227
+ try {
228
+ const buffer = await readEntryStream(zipFile, versionJsonEntry, jarPath, MAX_VERSION_JSON_BYTES);
229
+ versionJsonText = UTF8_DECODER.decode(buffer);
230
+ }
231
+ catch {
232
+ versionJsonText = undefined;
233
+ }
234
+ }
235
+ return {
236
+ hasJavaSources: false,
237
+ minecraftRuntimeSignals: {
238
+ hasSharedConstantsClass,
239
+ ...(versionJsonText !== undefined ? { versionJsonText } : {})
240
+ }
241
+ };
242
+ });
243
+ }
186
244
  export async function readJarEntryAsUtf8(jarPath, entryPath) {
187
245
  const contentBuffer = await readJarEntryAsBuffer(jarPath, entryPath);
188
246
  return decodeUtf8OrThrow(contentBuffer, jarPath, entryPath);
@@ -439,6 +497,9 @@ export async function collectMatchedJarEntriesAsBuffers(jarPath, predicate, opti
439
497
  if (maxEntries != null && entries.length >= maxEntries) {
440
498
  return entries;
441
499
  }
500
+ if (options.stopWhen?.(entries)) {
501
+ return entries;
502
+ }
442
503
  }
443
504
  catch (error) {
444
505
  if (!options.continueOnError) {
@@ -524,12 +585,41 @@ export async function detectFabricLikeInputNamespace(inputJar, deps = {}) {
524
585
  }
525
586
  // Read all sampled class entries in a SINGLE jar open instead of re-opening the
526
587
  // jar once per entry. The sample set and latin1 decode are unchanged, so scores —
527
- // and therefore the detected namespace — are identical.
588
+ // and therefore the detected namespace — are identical. The walk stops as soon
589
+ // as every sampled entry has been found instead of always scanning the rest of
590
+ // the jar.
591
+ //
592
+ // The predicate only checks name membership (not distinctness), so a readable
593
+ // duplicate-named entry can still be pushed. Stopping is instead driven by
594
+ // `stopWhen`, which runs only in the SUCCESS path (after a read has actually
595
+ // completed) and tracks distinct COLLECTED names there — not in the predicate,
596
+ // which runs before a read is attempted. A predicate-based dedup would mark a
597
+ // name "collected" optimistically before knowing whether its read succeeds; if
598
+ // the first copy of a duplicate name is unreadable (with `continueOnError:
599
+ // true`), that would permanently block a later, readable copy of the same name
600
+ // from ever being tried. The walk still stops once every distinct sampled name
601
+ // has at least one successful read, or the jar is exhausted.
528
602
  const sampleSet = new Set(classEntries);
529
- const matched = await collectMatchedJarEntriesAsBuffers(inputJar, (name) => sampleSet.has(name), { continueOnError: true }, deps);
603
+ const collectedNames = new Set();
604
+ const matched = await collectMatchedJarEntriesAsBuffers(inputJar, (name) => sampleSet.has(name), {
605
+ continueOnError: true,
606
+ stopWhen: (collected) => {
607
+ collectedNames.add(collected[collected.length - 1].filePath);
608
+ return collectedNames.size >= sampleSet.size;
609
+ }
610
+ }, deps);
611
+ // A readable duplicate of an already-collected name can still have been
612
+ // pushed above (the predicate does not dedupe); keep only the first
613
+ // successfully-read copy of each distinct name so each sampled class
614
+ // contributes to the score exactly once.
615
+ const seenForScoring = new Set();
530
616
  let mojangScore = 0;
531
617
  let intermediaryScore = 0;
532
- for (const { content } of matched) {
618
+ for (const { filePath, content } of matched) {
619
+ if (seenForScoring.has(filePath)) {
620
+ continue;
621
+ }
622
+ seenForScoring.add(filePath);
533
623
  const text = content.toString("latin1");
534
624
  mojangScore += countMatches(text, /net\/minecraft\/(?:advancements|client|commands|core|data|gametest|nbt|network|recipe|resources|server|sounds|stats|tags|util|world)\//g) * 3;
535
625
  intermediaryScore += countMatches(text, /net\/minecraft\/class_\d+/g) * 3;
@@ -1,5 +1,6 @@
1
1
  import type { Config, MappingVariant, ResolvedSourceArtifact, SourceTargetInput } from "./types.js";
2
2
  import { type MavenCoordinate } from "./maven-resolver.js";
3
+ import { type JavaSourceScan } from "./source-jar-reader.js";
3
4
  /**
4
5
  * Every `~/.m2` path a coordinate could name, before any of them is checked
5
6
  * against the filesystem.
@@ -59,6 +60,12 @@ export interface ResolveSourceTargetOptions {
59
60
  * legacy hash for obfuscated and source-backed artifacts.
60
61
  */
61
62
  mappingVariant?: MappingVariant;
63
+ /**
64
+ * Receives the result of walking the jar a `kind: "jar"` request names, once,
65
+ * when that walk ran. The resolver uses it to recognise a Minecraft runtime jar
66
+ * from its contents without walking the archive a second time.
67
+ */
68
+ onSubjectJarScanned?: (scan: JavaSourceScan) => void;
62
69
  onRepoFailover?: (event: {
63
70
  stage: "source" | "binary";
64
71
  repoUrl: string;