@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
@@ -99,11 +99,13 @@ export const resolveArtifactTargetSchema = z.discriminatedUnion("kind", [
99
99
  workspaceTargetSchema,
100
100
  dependencyTargetSchema
101
101
  ]);
102
- // Extended target schema for the source-lookup tools (get-class-source / get-class-members):
103
- // the same kind-based shape as resolveArtifactTargetSchema, PLUS a `kind:"artifact"` variant
104
- // that short-circuits resolution by reusing an already-resolved artifactId. The shared
105
- // resolveArtifactTargetSchema is intentionally NOT widened — the artifact kind has no
106
- // resolution meaning for resolve-artifact / verify-mixin-target / the batch tools.
102
+ // Extended target schema for the source-lookup tools (get-class-source / get-class-members)
103
+ // and the batch tools that fan out over one shared artifact (batch-class-source /
104
+ // batch-class-members): the same kind-based shape as resolveArtifactTargetSchema, PLUS a
105
+ // `kind:"artifact"` variant that short-circuits resolution by reusing an already-resolved
106
+ // artifactId. The shared resolveArtifactTargetSchema is intentionally NOT widened —
107
+ // the artifact kind has no resolution meaning for resolve-artifact / verify-mixin-target,
108
+ // which resolve a target rather than reuse one.
107
109
  export const sourceLookupTargetSchema = z.discriminatedUnion("kind", [
108
110
  z.object({ kind: z.literal("version"), value: nonEmptyString }),
109
111
  z.object({ kind: z.literal("jar"), value: nonEmptyString }),
@@ -238,7 +240,7 @@ export const batchClassSourceEntrySchema = z.object({
238
240
  outputFile: optionalNonEmptyString
239
241
  });
240
242
  export const batchClassSourceShape = {
241
- target: resolveArtifactTargetSchema.describe(RESOLVE_ARTIFACT_TARGET_DESCRIPTION),
243
+ target: sourceLookupTargetSchema.describe(SOURCE_LOOKUP_TARGET_DESCRIPTION),
242
244
  mapping: sourceMappingSchema.optional(),
243
245
  sourcePriority: mappingSourcePrioritySchema.optional(),
244
246
  allowDecompile: z.boolean().optional(),
@@ -294,7 +296,7 @@ export const batchClassMembersEntrySchema = z.object({
294
296
  maxMembers: optionalPositiveInt
295
297
  });
296
298
  export const batchClassMembersShape = {
297
- target: resolveArtifactTargetSchema.describe(RESOLVE_ARTIFACT_TARGET_DESCRIPTION),
299
+ target: sourceLookupTargetSchema.describe(SOURCE_LOOKUP_TARGET_DESCRIPTION),
298
300
  mapping: sourceMappingSchema.optional(),
299
301
  sourcePriority: mappingSourcePrioritySchema.optional(),
300
302
  allowDecompile: z.boolean().optional(),
package/dist/types.d.ts CHANGED
@@ -75,6 +75,15 @@ export interface ArtifactProvenance {
75
75
  repoUrl?: string;
76
76
  };
77
77
  transformChain: string[];
78
+ /**
79
+ * The artifact's runtime names ship unobfuscated (Minecraft 26.1+): set for a
80
+ * 26.1+ version target, a 26.1+ Minecraft runtime coordinate, and a jar target
81
+ * proven to be a 26.1+ runtime jar; absent otherwise. `mappingApplied` keeps the
82
+ * label the caller asked for, so on such an artifact "obfuscated" names the
83
+ * as-shipped names, which are already Mojang names. Same flag name as
84
+ * `mappingContext.unobfuscatedRuntime`.
85
+ */
86
+ unobfuscatedRuntime?: boolean;
78
87
  workspaceResolution?: WorkspaceResolutionProvenance;
79
88
  dependencyResolution?: DependencyResolutionProvenance;
80
89
  warnings?: string[];
@@ -446,9 +446,26 @@ export const V1_PARITY_SCHEMAS = {
446
446
  "name"
447
447
  ],
448
448
  "additionalProperties": false
449
+ },
450
+ {
451
+ "type": "object",
452
+ "properties": {
453
+ "kind": {
454
+ "type": "string",
455
+ "const": "artifact"
456
+ },
457
+ "artifactId": {
458
+ "$ref": "#/properties/target/anyOf/0/properties/value"
459
+ }
460
+ },
461
+ "required": [
462
+ "kind",
463
+ "artifactId"
464
+ ],
465
+ "additionalProperties": false
449
466
  }
450
467
  ],
451
- "description": "Object, not string. e.g. {\"kind\":\"version\",\"value\":\"1.21.10\"}, {\"kind\":\"workspace\"}, or to inspect a loader/Fabric dependency like vanilla: {\"kind\":\"dependency\",\"group\":\"net.fabricmc.fabric-api\",\"name\":\"fabric-api\",\"versionFromProject\":true} (needs projectPath) or with an explicit \"version\"."
468
+ "description": "Same shape as resolve-artifact target (incl. {\"kind\":\"dependency\",...} to read a Fabric/loader dependency class like vanilla), plus {\"kind\":\"artifact\",\"artifactId\":\"...\"} to reuse a resolved artifact. Object, not string."
452
469
  },
453
470
  "mapping": {
454
471
  "type": "string",
@@ -706,9 +723,26 @@ export const V1_PARITY_SCHEMAS = {
706
723
  "name"
707
724
  ],
708
725
  "additionalProperties": false
726
+ },
727
+ {
728
+ "type": "object",
729
+ "properties": {
730
+ "kind": {
731
+ "type": "string",
732
+ "const": "artifact"
733
+ },
734
+ "artifactId": {
735
+ "$ref": "#/properties/target/anyOf/0/properties/value"
736
+ }
737
+ },
738
+ "required": [
739
+ "kind",
740
+ "artifactId"
741
+ ],
742
+ "additionalProperties": false
709
743
  }
710
744
  ],
711
- "description": "Object, not string. e.g. {\"kind\":\"version\",\"value\":\"1.21.10\"}, {\"kind\":\"workspace\"}, or to inspect a loader/Fabric dependency like vanilla: {\"kind\":\"dependency\",\"group\":\"net.fabricmc.fabric-api\",\"name\":\"fabric-api\",\"versionFromProject\":true} (needs projectPath) or with an explicit \"version\"."
745
+ "description": "Same shape as resolve-artifact target (incl. {\"kind\":\"dependency\",...} to read a Fabric/loader dependency class like vanilla), plus {\"kind\":\"artifact\",\"artifactId\":\"...\"} to reuse a resolved artifact. Object, not string."
712
746
  },
713
747
  "mapping": {
714
748
  "type": "string",
@@ -52,6 +52,29 @@ export type CompareVersionsOutput = {
52
52
  };
53
53
  warnings: string[];
54
54
  };
55
+ /**
56
+ * `added`/`removed` entries are `group:artifact:<versions>`, where
57
+ * `<versions>` is the sorted, comma-joined set of distinct versions seen for
58
+ * that key on the side it was diffed from or to (a single version renders
59
+ * as before, e.g. "g:a:1"; multiple platform-specific versions render as
60
+ * "g:a:1,2").
61
+ */
62
+ export type LibraryDiffResult = {
63
+ added: string[];
64
+ removed: string[];
65
+ addedCount: number;
66
+ removedCount: number;
67
+ /** Count only (per design contract) — no list of which libraries bumped. */
68
+ versionChangedCount: number;
69
+ };
70
+ /**
71
+ * Pure diff of two versions' `libraries` name lists, keyed by
72
+ * `group:artifact` so a per-release version bump or a per-platform natives
73
+ * classifier never reads as churn. Used by migration-overview to surface
74
+ * library swaps (e.g. LWJGL GLFW replaced by SDL) that a class/registry diff
75
+ * cannot see.
76
+ */
77
+ export declare function diffLibraries(fromNames: string[], toNames: string[]): LibraryDiffResult;
55
78
  export declare class VersionDiffService {
56
79
  private readonly config;
57
80
  private readonly versionService;
@@ -88,6 +88,107 @@ function diffSets(from, to) {
88
88
  removed.sort();
89
89
  return { added, removed, unchanged };
90
90
  }
91
+ // Matches a Maven-style library coordinate "group:artifact:version" with an
92
+ // optional trailing classifier ("...:natives-linux"). Group/artifact/version
93
+ // segments must be non-empty and colon-free; anything else is not a library
94
+ // name this diff can key on, so it is silently ignored rather than crashing
95
+ // or reading as a spurious added/removed entry. A trailing "@extension"
96
+ // (e.g. "...@jar") is stripped by the caller before this regex runs, so it
97
+ // never leaks into the captured version.
98
+ const LIBRARY_NAME_RE = /^([^:\s]+):([^:\s]+):([^:\s]+)(?::[^:\s]*)?$/;
99
+ function parseLibraryName(name) {
100
+ // Strip a trailing "@extension" (e.g. "g:a:1@jar", "g:a:1:natives-linux@jar")
101
+ // before parsing, so it never gets captured as part of the version.
102
+ const withoutExtension = name.trim().replace(/@[^@]*$/, "");
103
+ const match = LIBRARY_NAME_RE.exec(withoutExtension);
104
+ if (!match) {
105
+ return undefined;
106
+ }
107
+ const [, group, artifact, version] = match;
108
+ return { key: `${group}:${artifact}`, version };
109
+ }
110
+ /**
111
+ * Collapses a version's raw library name list to the set of distinct
112
+ * versions seen per `group:artifact` key, dropping the per-platform natives
113
+ * classifier and any entry whose name does not parse as a library
114
+ * coordinate. Real Mojang manifests can list the same `group:artifact` with
115
+ * different versions under different platform rules, so every version seen
116
+ * is kept (not just the first) — collapsing to one would make the diff
117
+ * depend on input order.
118
+ */
119
+ function collapseLibraryNames(names) {
120
+ const map = new Map();
121
+ for (const name of names) {
122
+ if (typeof name !== "string") {
123
+ continue;
124
+ }
125
+ const parsed = parseLibraryName(name);
126
+ if (!parsed) {
127
+ continue;
128
+ }
129
+ let versions = map.get(parsed.key);
130
+ if (!versions) {
131
+ versions = new Set();
132
+ map.set(parsed.key, versions);
133
+ }
134
+ versions.add(parsed.version);
135
+ }
136
+ return map;
137
+ }
138
+ /** Renders a key's version set as the sorted, comma-joined suffix used in `added`/`removed`. */
139
+ function renderVersions(versions) {
140
+ return Array.from(versions).sort().join(",");
141
+ }
142
+ function versionSetsEqual(a, b) {
143
+ if (a.size !== b.size) {
144
+ return false;
145
+ }
146
+ for (const version of a) {
147
+ if (!b.has(version)) {
148
+ return false;
149
+ }
150
+ }
151
+ return true;
152
+ }
153
+ /**
154
+ * Pure diff of two versions' `libraries` name lists, keyed by
155
+ * `group:artifact` so a per-release version bump or a per-platform natives
156
+ * classifier never reads as churn. Used by migration-overview to surface
157
+ * library swaps (e.g. LWJGL GLFW replaced by SDL) that a class/registry diff
158
+ * cannot see.
159
+ */
160
+ export function diffLibraries(fromNames, toNames) {
161
+ const fromMap = collapseLibraryNames(fromNames ?? []);
162
+ const toMap = collapseLibraryNames(toNames ?? []);
163
+ const added = [];
164
+ const removed = [];
165
+ let versionChangedCount = 0;
166
+ for (const [key, versions] of toMap) {
167
+ if (!fromMap.has(key)) {
168
+ added.push(`${key}:${renderVersions(versions)}`);
169
+ }
170
+ }
171
+ for (const [key, versions] of fromMap) {
172
+ if (!toMap.has(key)) {
173
+ removed.push(`${key}:${renderVersions(versions)}`);
174
+ }
175
+ }
176
+ for (const [key, fromVersions] of fromMap) {
177
+ const toVersions = toMap.get(key);
178
+ if (toVersions !== undefined && !versionSetsEqual(fromVersions, toVersions)) {
179
+ versionChangedCount += 1;
180
+ }
181
+ }
182
+ added.sort();
183
+ removed.sort();
184
+ return {
185
+ added,
186
+ removed,
187
+ addedCount: added.length,
188
+ removedCount: removed.length,
189
+ versionChangedCount
190
+ };
191
+ }
91
192
  function diffRegistries(fromRegistries, toRegistries) {
92
193
  const fromKeys = new Set(Object.keys(fromRegistries));
93
194
  const toKeys = new Set(Object.keys(toRegistries));
@@ -54,6 +54,14 @@ export declare class VersionService {
54
54
  listVersionIds(input?: ListVersionIdsInput): Promise<string[]>;
55
55
  resolveVersionJar(version: string): Promise<ResolvedVersionJar>;
56
56
  resolveVersionMappings(version: string): Promise<ResolvedVersionMappings>;
57
+ /**
58
+ * Raw `libraries[].name` coordinates (e.g. "org.lwjgl:lwjgl-glfw:3.4.1")
59
+ * for one version's per-version JSON, in manifest order. Entries missing a
60
+ * non-empty string `name` are dropped here at the JSON boundary; further
61
+ * validation (does the name actually parse as a library coordinate) is the
62
+ * caller's job — see diffLibraries in version-diff-service.ts.
63
+ */
64
+ getVersionLibraries(version: string): Promise<string[]>;
57
65
  resolveServerJar(version: string): Promise<ResolvedServerJar>;
58
66
  private fetchManifest;
59
67
  private resolveVersionJarInternal;
@@ -69,5 +77,11 @@ export declare class VersionService {
69
77
  * MC 26.1+ uses new YY.N version format and ships unobfuscated source.
70
78
  * Legacy 1.x.y versions remain obfuscated.
71
79
  * Snapshots: "26w01a" (year >= 26) → unobfuscated, "24w01a" → obfuscated.
80
+ *
81
+ * Pre-release / rc / snapshot suffixes: Mojang's real version-manifest ids
82
+ * use a hyphenated "-pre-N" / "-rc-N" / "-snapshot-N" form (e.g.
83
+ * "26.2-pre-6", "26.2-rc-2", "26.3-snapshot-6"). The older no-hyphen
84
+ * "-preN"/"-rcN" form is also accepted for backwards compatibility, but an
85
+ * arbitrary or numberless suffix (e.g. "26.1-foo", "26.1-snapshot") is not.
72
86
  */
73
87
  export declare function isUnobfuscatedVersion(version: string): boolean;
@@ -136,6 +136,39 @@ export class VersionService {
136
136
  mappingsUrl: clientMappingsUrl
137
137
  };
138
138
  }
139
+ /**
140
+ * Raw `libraries[].name` coordinates (e.g. "org.lwjgl:lwjgl-glfw:3.4.1")
141
+ * for one version's per-version JSON, in manifest order. Entries missing a
142
+ * non-empty string `name` are dropped here at the JSON boundary; further
143
+ * validation (does the name actually parse as a library coordinate) is the
144
+ * caller's job — see diffLibraries in version-diff-service.ts.
145
+ */
146
+ async getVersionLibraries(version) {
147
+ const normalizedVersion = version.trim();
148
+ if (!normalizedVersion) {
149
+ throw createError({
150
+ code: ERROR_CODES.INVALID_INPUT,
151
+ message: "version must be non-empty."
152
+ });
153
+ }
154
+ const manifest = await this.fetchManifest();
155
+ const versionEntry = (manifest.versions ?? []).find((entry) => entry.id === normalizedVersion);
156
+ if (!versionEntry) {
157
+ throw createError({
158
+ code: ERROR_CODES.VERSION_NOT_FOUND,
159
+ message: `Minecraft version "${normalizedVersion}" was not found in version manifest.`,
160
+ details: {
161
+ version: normalizedVersion,
162
+ nextAction: "Use list-versions to see available Minecraft versions.",
163
+ ...buildSuggestedCall({ tool: "list-versions", params: {} })
164
+ }
165
+ });
166
+ }
167
+ const details = await this.fetchVersionDetails(versionEntry.url, normalizedVersion);
168
+ return (details.libraries ?? [])
169
+ .map((lib) => lib?.name)
170
+ .filter((name) => typeof name === "string" && name.length > 0);
171
+ }
139
172
  async resolveServerJar(version) {
140
173
  const normalizedVersion = version.trim();
141
174
  if (!normalizedVersion) {
@@ -357,6 +390,13 @@ export class VersionService {
357
390
  try {
358
391
  const response = await this.fetchFn(url, { signal: timeout.signal });
359
392
  if (!response.ok) {
393
+ // Nothing reads an error body here, and an unread one pins its socket
394
+ // until GC reaches it. Released best-effort, exactly as `releaseBody`
395
+ // in repo-downloader.ts: a body that refuses to be cancelled is not a
396
+ // reason to lose the status the caller actually needs.
397
+ void response.body?.cancel().catch(() => {
398
+ // best-effort release
399
+ });
360
400
  throw createError({
361
401
  code: ERROR_CODES.REPO_FETCH_FAILED,
362
402
  message: `Request failed for "${url}" with status ${response.status}.`,
@@ -473,6 +513,12 @@ export class VersionService {
473
513
  * MC 26.1+ uses new YY.N version format and ships unobfuscated source.
474
514
  * Legacy 1.x.y versions remain obfuscated.
475
515
  * Snapshots: "26w01a" (year >= 26) → unobfuscated, "24w01a" → obfuscated.
516
+ *
517
+ * Pre-release / rc / snapshot suffixes: Mojang's real version-manifest ids
518
+ * use a hyphenated "-pre-N" / "-rc-N" / "-snapshot-N" form (e.g.
519
+ * "26.2-pre-6", "26.2-rc-2", "26.3-snapshot-6"). The older no-hyphen
520
+ * "-preN"/"-rcN" form is also accepted for backwards compatibility, but an
521
+ * arbitrary or numberless suffix (e.g. "26.1-foo", "26.1-snapshot") is not.
476
522
  */
477
523
  export function isUnobfuscatedVersion(version) {
478
524
  if (!version)
@@ -482,9 +528,12 @@ export function isUnobfuscatedVersion(version) {
482
528
  if (snapshotMatch) {
483
529
  return Number(snapshotMatch[1]) >= 26;
484
530
  }
485
- // New format: YY.N or YY.N.P, optionally with -preN/-rcN suffix.
486
- // Examples: "26.1", "27.3.1", "26.1-pre1", "26.1-rc1"
487
- const newFormatMatch = version.match(/^(\d{2,})\.\d+(?:\.\d+)?(?:-(?:pre|rc)\d+)?$/);
531
+ // New format: YY.N or YY.N.P, optionally with a pre-release/rc/snapshot
532
+ // suffix in either the legacy no-hyphen form (-preN, -rcN) or Mojang's
533
+ // real hyphenated form (-pre-N, -rc-N, -snapshot-N).
534
+ // Examples: "26.1", "27.3.1", "26.1-pre1", "26.1-rc1", "26.2-pre-6",
535
+ // "26.2-rc-2", "26.3-snapshot-6"
536
+ const newFormatMatch = version.match(/^(\d{2,})\.\d+(?:\.\d+)?(?:-(?:pre|rc)\d+|-(?:pre|rc|snapshot)-\d+)?$/);
488
537
  if (newFormatMatch) {
489
538
  return Number(newFormatMatch[1]) >= 26;
490
539
  }
@@ -5,6 +5,22 @@ export type WorkspaceContextEvidence = {
5
5
  field: string;
6
6
  value?: string;
7
7
  };
8
+ /**
9
+ * A stat-based snapshot of one file the detection that produced a
10
+ * `WorkspaceContext` actually read, taken at write time. `read()` re-stats
11
+ * every entry and treats any difference - a changed mtime/size, a file that
12
+ * appeared, or one that disappeared - as a cache miss, so an edit to a
13
+ * project's build files invalidates the cached context immediately instead
14
+ * of waiting out the TTL.
15
+ */
16
+ export type WorkspaceContextFingerprintEntry = {
17
+ path: string;
18
+ mtimeMs: number;
19
+ size: number;
20
+ } | {
21
+ path: string;
22
+ absent: true;
23
+ };
8
24
  export type WorkspaceContext = {
9
25
  projectPath: string;
10
26
  minecraftVersion?: string;
@@ -14,6 +30,7 @@ export type WorkspaceContext = {
14
30
  evidence: WorkspaceContextEvidence[];
15
31
  dependencyVersions: Map<string, string>;
16
32
  partial?: boolean;
33
+ fingerprint?: WorkspaceContextFingerprintEntry[];
17
34
  };
18
35
  export interface WorkspaceContextCache {
19
36
  read(projectPath: string): WorkspaceContext | undefined;
@@ -27,6 +44,14 @@ export type WorkspaceContextCacheOptions = {
27
44
  ttlMs?: number;
28
45
  clock?: () => number;
29
46
  };
47
+ /**
48
+ * Stat-snapshot the given file paths (deduplicated, order-independent) for a
49
+ * `WorkspaceContext.fingerprint`. Exported so the workspace-context populator
50
+ * (the only place that knows which files a detection actually read) can build
51
+ * the same shape `read()` compares against, without duplicating the stat
52
+ * logic or its absent-file handling.
53
+ */
54
+ export declare function computeWorkspaceContextFingerprint(paths: readonly string[]): WorkspaceContextFingerprintEntry[];
30
55
  export declare function createWorkspaceContextCache(opts?: WorkspaceContextCacheOptions): WorkspaceContextCache;
31
56
  export declare function getProcessWorkspaceContextCache(): WorkspaceContextCache;
32
57
  export declare function resetProcessWorkspaceContextCacheForTesting(): void;
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { resolve as resolvePath } from "node:path";
2
3
  import { LruList } from "./lru-list.js";
3
4
  const DEFAULT_MAX_ENTRIES = 16;
@@ -5,6 +6,55 @@ const DEFAULT_TTL_MS = 5 * 60_000;
5
6
  function normalizeKey(projectPath) {
6
7
  return resolvePath(projectPath);
7
8
  }
9
+ function currentFingerprintEntry(path) {
10
+ try {
11
+ const stats = statSync(path);
12
+ return { path, mtimeMs: stats.mtimeMs, size: stats.size };
13
+ }
14
+ catch {
15
+ // Missing, unreadable, or a broken symlink - all the same "not there"
16
+ // state as far as invalidation cares about.
17
+ return { path, absent: true };
18
+ }
19
+ }
20
+ /**
21
+ * Stat-snapshot the given file paths (deduplicated, order-independent) for a
22
+ * `WorkspaceContext.fingerprint`. Exported so the workspace-context populator
23
+ * (the only place that knows which files a detection actually read) can build
24
+ * the same shape `read()` compares against, without duplicating the stat
25
+ * logic or its absent-file handling.
26
+ */
27
+ export function computeWorkspaceContextFingerprint(paths) {
28
+ const unique = Array.from(new Set(paths.map((path) => resolvePath(path))));
29
+ return unique.map((path) => currentFingerprintEntry(path));
30
+ }
31
+ /**
32
+ * Whether any file recorded in `ctx.fingerprint` no longer matches its
33
+ * recorded size/mtime (or its recorded/current absence disagrees). A context
34
+ * with no fingerprint (nothing was recorded, e.g. a dependency-only partial
35
+ * entry) never invalidates on this check - only the TTL applies to it.
36
+ */
37
+ function fingerprintStale(ctx) {
38
+ if (!ctx.fingerprint || ctx.fingerprint.length === 0) {
39
+ return false;
40
+ }
41
+ for (const recorded of ctx.fingerprint) {
42
+ const current = currentFingerprintEntry(recorded.path);
43
+ const recordedAbsent = "absent" in recorded && recorded.absent === true;
44
+ const currentAbsent = "absent" in current && current.absent === true;
45
+ if (recordedAbsent !== currentAbsent) {
46
+ return true;
47
+ }
48
+ if (!recordedAbsent && !currentAbsent) {
49
+ const recordedStat = recorded;
50
+ const currentStat = current;
51
+ if (recordedStat.mtimeMs !== currentStat.mtimeMs || recordedStat.size !== currentStat.size) {
52
+ return true;
53
+ }
54
+ }
55
+ }
56
+ return false;
57
+ }
8
58
  export function createWorkspaceContextCache(opts = {}) {
9
59
  const maxEntries = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
10
60
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
@@ -29,7 +79,7 @@ export function createWorkspaceContextCache(opts = {}) {
29
79
  if (!value) {
30
80
  return undefined;
31
81
  }
32
- if (isExpired(value)) {
82
+ if (isExpired(value) || fingerprintStale(value)) {
33
83
  lru.remove(key);
34
84
  return undefined;
35
85
  }
@@ -46,7 +96,7 @@ export function createWorkspaceContextCache(opts = {}) {
46
96
  },
47
97
  list() {
48
98
  const all = lru.toArray().map((entry) => entry.value);
49
- return all.filter((value) => !isExpired(value));
99
+ return all.filter((value) => !isExpired(value) && !fingerprintStale(value));
50
100
  },
51
101
  clear() {
52
102
  lru.clear();
@@ -50,6 +50,14 @@ export type DependencyVersionOptions = {
50
50
  export { isSafeMavenVersionToken };
51
51
  export declare class WorkspaceMappingService {
52
52
  detectCompileMapping(input: WorkspaceCompileMappingInput): Promise<WorkspaceCompileMappingOutput>;
53
+ /**
54
+ * Whether `projectPath` holds at least one build.gradle(.kts) that
55
+ * `detectCompileMapping` reads: the same files, found the same way, and readable.
56
+ * `detectCompileMapping` answers "no evidence" both when a build declares no
57
+ * mappings and when there was no build to read (a missing, mistyped or empty
58
+ * directory); this tells the two apart without changing that output.
59
+ */
60
+ hasReadableBuildScript(projectPath: string): Promise<boolean>;
53
61
  detectProjectMinecraftVersion(projectPath: string): Promise<string | undefined>;
54
62
  detectDependencyVersion(projectPath: string, group: string, name: string, opts?: DependencyVersionOptions): Promise<DependencyVersionResolution>;
55
63
  detectProjectLoader(projectPath: string): Promise<WorkspaceProjectLoaderOutput>;